omp-conductor 0.13.0 → 0.15.0
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 +549 -234
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/availability.ts +165 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +72 -31
- package/src/briefs/policy.md +48 -36
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +356 -212
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1037 -679
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +644 -390
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +89 -22
- package/src/fleet.ts +351 -46
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +732 -56
- package/src/privileged.ts +264 -0
- package/src/reports.ts +203 -6
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/setup-wizard.ts +1946 -0
- package/src/setup.ts +457 -53
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +153 -14
- package/src/upgrade.ts +44 -10
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +40 -18
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +24 -7
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
- package/src/plugin.ts +0 -1495
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical operator availability in an IANA timezone (#273).
|
|
3
|
+
*
|
|
4
|
+
* This module owns the clock decision. Callers supply `now`; neither a model nor
|
|
5
|
+
* process-local memory decides whether an interruption is allowed. UTC-minute
|
|
6
|
+
* scanning for the next transition is intentional: evaluating the same local
|
|
7
|
+
* predicate across real instants handles skipped and repeated DST wall-clock
|
|
8
|
+
* minutes without inventing an offset conversion of our own.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { WEEKDAYS, type InterruptCategory, type ReportingPolicy, type Weekday, type WeeklyAvailability } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const MINUTE_MS = 60_000;
|
|
14
|
+
const TRANSITION_HORIZON_MS = 15 * 24 * 60 * MINUTE_MS;
|
|
15
|
+
|
|
16
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
17
|
+
interface CachedTransition {
|
|
18
|
+
from: number;
|
|
19
|
+
until: number;
|
|
20
|
+
open: boolean;
|
|
21
|
+
nextTransitionAt?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const transitions = new WeakMap<WeeklyAvailability, CachedTransition>();
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
function formatter(timezone: string): Intl.DateTimeFormat {
|
|
28
|
+
const existing = formatters.get(timezone);
|
|
29
|
+
if (existing !== undefined) return existing;
|
|
30
|
+
const created = new Intl.DateTimeFormat("en-GB", {
|
|
31
|
+
timeZone: timezone,
|
|
32
|
+
weekday: "short",
|
|
33
|
+
year: "numeric",
|
|
34
|
+
month: "2-digit",
|
|
35
|
+
day: "2-digit",
|
|
36
|
+
hour: "2-digit",
|
|
37
|
+
minute: "2-digit",
|
|
38
|
+
hourCycle: "h23",
|
|
39
|
+
});
|
|
40
|
+
formatters.set(timezone, created);
|
|
41
|
+
return created;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface LocalMinute {
|
|
45
|
+
day: Weekday;
|
|
46
|
+
date: string;
|
|
47
|
+
clock: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function localMinute(at: number, timezone: string): LocalMinute {
|
|
51
|
+
const parts = formatter(timezone).formatToParts(new Date(at));
|
|
52
|
+
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
53
|
+
parts.find((part) => part.type === type)?.value ?? "";
|
|
54
|
+
return {
|
|
55
|
+
day: get("weekday").slice(0, 3).toLowerCase() as Weekday,
|
|
56
|
+
date: `${get("year")}-${get("month")}-${get("day")}`,
|
|
57
|
+
clock: `${get("hour")}:${get("minute")}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function previousDay(day: Weekday): Weekday {
|
|
62
|
+
const index = WEEKDAYS.indexOf(day);
|
|
63
|
+
return WEEKDAYS[(index + WEEKDAYS.length - 1) % WEEKDAYS.length]!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether `at` falls inside the configured local weekly window. */
|
|
67
|
+
export function availabilityOpen(window: WeeklyAvailability | undefined, at: number): boolean {
|
|
68
|
+
if (window === undefined) return true;
|
|
69
|
+
const local = localMinute(at, window.timezone);
|
|
70
|
+
if (window.start < window.end) {
|
|
71
|
+
return window.days.includes(local.day) && local.clock >= window.start && local.clock < window.end;
|
|
72
|
+
}
|
|
73
|
+
// Overnight: a selected day opens at `start` and remains open on the next
|
|
74
|
+
// local day until `end`.
|
|
75
|
+
return (
|
|
76
|
+
(window.days.includes(local.day) && local.clock >= window.start) ||
|
|
77
|
+
(window.days.includes(previousDay(local.day)) && local.clock < window.end)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type InterruptDisposition = "interrupt" | "digest" | "availability";
|
|
82
|
+
|
|
83
|
+
export function availabilityDisposition(
|
|
84
|
+
window: WeeklyAvailability | undefined,
|
|
85
|
+
category: InterruptCategory,
|
|
86
|
+
at: number,
|
|
87
|
+
): Exclude<InterruptDisposition, "digest"> {
|
|
88
|
+
if (window === undefined || availabilityOpen(window, at) || window.bypass.includes(category)) {
|
|
89
|
+
return "interrupt";
|
|
90
|
+
}
|
|
91
|
+
return "availability";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decide one tier-2 category. `digest` means the category policy itself defers
|
|
96
|
+
* it; `availability` means it was otherwise interruptible and may be released
|
|
97
|
+
* when the configured window next opens.
|
|
98
|
+
*/
|
|
99
|
+
export function interruptDisposition(
|
|
100
|
+
policy: ReportingPolicy | undefined,
|
|
101
|
+
category: InterruptCategory,
|
|
102
|
+
at: number,
|
|
103
|
+
): InterruptDisposition {
|
|
104
|
+
if (policy !== undefined && !policy.interruptOn.includes(category)) return "digest";
|
|
105
|
+
return availabilityDisposition(policy?.availability, category, at);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AvailabilityState {
|
|
109
|
+
mode: "always" | "working" | "quiet";
|
|
110
|
+
timezone?: string;
|
|
111
|
+
nextTransitionAt?: number;
|
|
112
|
+
bypass: InterruptCategory[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Current mode plus the first real instant at which that mode changes. */
|
|
116
|
+
export function availabilityState(policy: ReportingPolicy | undefined, now: number): AvailabilityState {
|
|
117
|
+
const window = policy?.availability;
|
|
118
|
+
if (window === undefined) return { mode: "always", bypass: [] };
|
|
119
|
+
|
|
120
|
+
const from = Math.floor(now / MINUTE_MS) * MINUTE_MS;
|
|
121
|
+
let cached = transitions.get(window);
|
|
122
|
+
if (cached === undefined || from < cached.from || from >= cached.until) {
|
|
123
|
+
const open = availabilityOpen(window, now);
|
|
124
|
+
const end = now + TRANSITION_HORIZON_MS;
|
|
125
|
+
let cursor = from + MINUTE_MS;
|
|
126
|
+
while (cursor <= end && availabilityOpen(window, cursor) === open) cursor += MINUTE_MS;
|
|
127
|
+
cached = {
|
|
128
|
+
from,
|
|
129
|
+
until: cursor,
|
|
130
|
+
open,
|
|
131
|
+
...(cursor > end ? {} : { nextTransitionAt: cursor }),
|
|
132
|
+
};
|
|
133
|
+
transitions.set(window, cached);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { open, nextTransitionAt } = cached;
|
|
137
|
+
return {
|
|
138
|
+
mode: open ? "working" : "quiet",
|
|
139
|
+
timezone: window.timezone,
|
|
140
|
+
...(nextTransitionAt === undefined ? {} : { nextTransitionAt }),
|
|
141
|
+
bypass: [...window.bypass],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stable operator-facing local timestamp for status and tick prompts. */
|
|
146
|
+
export function formatZonedMinute(at: number, timezone: string): string {
|
|
147
|
+
const local = localMinute(at, timezone);
|
|
148
|
+
return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One prompt sentence; the runtime gate, not this prose, owns the decision. */
|
|
152
|
+
export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
|
|
153
|
+
const state = availabilityState(policy, now);
|
|
154
|
+
if (state.mode === "always") {
|
|
155
|
+
return "Operator availability (mechanical): 24-hour interrupts; no working-hours window is configured.";
|
|
156
|
+
}
|
|
157
|
+
const until =
|
|
158
|
+
state.nextTransitionAt === undefined || state.timezone === undefined
|
|
159
|
+
? "the next configured transition"
|
|
160
|
+
: formatZonedMinute(state.nextTransitionAt, state.timezone);
|
|
161
|
+
const bypass = state.bypass.length === 0 ? "none" : state.bypass.join(", ");
|
|
162
|
+
return state.mode === "working"
|
|
163
|
+
? `Operator availability (mechanical): working until ${until}; outside-hours bypass: ${bypass}.`
|
|
164
|
+
: `Operator availability (mechanical): quiet until ${until}; only these categories bypass quiet hours: ${bypass}.`;
|
|
165
|
+
}
|
package/src/board.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
codeGraphFromHealthz,
|
|
8
8
|
fleetLayers,
|
|
9
9
|
probeTelegramHealth,
|
|
10
|
+
workerPhasesFromHealthz,
|
|
10
11
|
type FleetLayers,
|
|
11
12
|
type TelegramHealth,
|
|
12
13
|
} from "./fleet.ts";
|
|
@@ -922,36 +923,6 @@ export function renderBoard(
|
|
|
922
923
|
].join("\n");
|
|
923
924
|
}
|
|
924
925
|
|
|
925
|
-
export function workerPhasesFromHealthz(
|
|
926
|
-
body: string | undefined,
|
|
927
|
-
project: string,
|
|
928
|
-
): ReadonlyMap<number, WorkerPausePhase> {
|
|
929
|
-
const phases = new Map<number, WorkerPausePhase>();
|
|
930
|
-
if (body === undefined) return phases;
|
|
931
|
-
try {
|
|
932
|
-
const payload = JSON.parse(body) as unknown;
|
|
933
|
-
if (payload === null || typeof payload !== "object") return phases;
|
|
934
|
-
if (Reflect.get(payload, "project") !== project) return phases;
|
|
935
|
-
const workers = Reflect.get(payload, "workers");
|
|
936
|
-
if (!Array.isArray(workers)) return phases;
|
|
937
|
-
for (const worker of workers) {
|
|
938
|
-
if (worker === null || typeof worker !== "object") continue;
|
|
939
|
-
const issue = Reflect.get(worker, "issue");
|
|
940
|
-
const phase = Reflect.get(worker, "phase");
|
|
941
|
-
if (
|
|
942
|
-
Number.isSafeInteger(issue) &&
|
|
943
|
-
(issue as number) > 0 &&
|
|
944
|
-
(phase === "pausing" || phase === "paused")
|
|
945
|
-
) {
|
|
946
|
-
phases.set(issue as number, phase);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
} catch {
|
|
950
|
-
// An unreadable health body means no trustworthy pause phase.
|
|
951
|
-
}
|
|
952
|
-
return phases;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
926
|
async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
|
|
956
927
|
const layers = fleetLayers(project.name);
|
|
957
928
|
const record = livingDaemon();
|
|
@@ -966,8 +937,24 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProb
|
|
|
966
937
|
else if (health?.ok !== true) daemon = "unreachable";
|
|
967
938
|
else {
|
|
968
939
|
try {
|
|
969
|
-
const payload = JSON.parse(health.body ?? "null") as
|
|
970
|
-
|
|
940
|
+
const payload = JSON.parse(health.body ?? "null") as unknown;
|
|
941
|
+
if (payload === null || typeof payload !== "object") {
|
|
942
|
+
daemon = "unreachable";
|
|
943
|
+
} else if (Reflect.get(payload, "project") === project.name) {
|
|
944
|
+
daemon = "ok";
|
|
945
|
+
} else {
|
|
946
|
+
const projects = Reflect.get(payload, "projects");
|
|
947
|
+
daemon =
|
|
948
|
+
Array.isArray(projects) &&
|
|
949
|
+
projects.some(
|
|
950
|
+
(entry) =>
|
|
951
|
+
entry !== null &&
|
|
952
|
+
typeof entry === "object" &&
|
|
953
|
+
Reflect.get(entry, "project") === project.name,
|
|
954
|
+
)
|
|
955
|
+
? "ok"
|
|
956
|
+
: "other-project";
|
|
957
|
+
}
|
|
971
958
|
} catch {
|
|
972
959
|
daemon = "unreachable";
|
|
973
960
|
}
|
package/src/brief-upgrade.ts
CHANGED
|
@@ -575,7 +575,7 @@ export function formatBriefReport(path: string, layout: BriefLayout, missing: re
|
|
|
575
575
|
return [
|
|
576
576
|
`brief ${path}`,
|
|
577
577
|
"",
|
|
578
|
-
"No brief here yet. Run
|
|
578
|
+
"No brief here yet. Run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md;",
|
|
579
579
|
"it writes POLICY.md beside it, which is the half you then own.",
|
|
580
580
|
].join("\n");
|
|
581
581
|
}
|
|
@@ -16,8 +16,10 @@ Point the heartbeat at the workspace that holds `ORCHESTRATOR.md` /
|
|
|
16
16
|
`POLICY.md` — a `.conductor-tick.json` whose default message re-reads both.
|
|
17
17
|
|
|
18
18
|
This floor ships conservative so an unedited `POLICY.md` is still a safe fleet.
|
|
19
|
-
To tailor Releases and Project context,
|
|
20
|
-
|
|
19
|
+
To tailor Releases and Project context, run `omp-conductor setup brief`: it asks
|
|
20
|
+
the judgment questions no repo reading can answer, then reads your repos to draft
|
|
21
|
+
the rest and shows each draft for confirmation. Editing `POLICY.md` by hand does
|
|
22
|
+
the same job.
|
|
21
23
|
|
|
22
24
|
---
|
|
23
25
|
|
|
@@ -154,6 +156,7 @@ to notice it unaided.
|
|
|
154
156
|
| `undisclosed-file` | The PR touched a file the report never mentioned. | Open the diff for that file. An undisclosed edit is usually incidental — a lockfile, a formatter — and occasionally the whole story. |
|
|
155
157
|
| `changed-line-missing` | The report disclosed nothing at all. | Read the diff before merging; you have no summary of it. |
|
|
156
158
|
| `unmatched-claim` | The report named a file the PR never touched. | Weak on its own. Two or three together mean the report was written from memory rather than from `git diff`, so trust the rest of it less. |
|
|
159
|
+
| `report-format-unparsed` | The audit could not parse the report's format. | Not a trust signal against the worker — read the diff directly. |
|
|
157
160
|
| `test-file-deleted` | A test file left the tree and no rename explains it. | The one that most deserves a human. Was the behaviour it defended deleted too, or only its test? |
|
|
158
161
|
| `test-disabled` | A `.skip` / `.only` / `xit` / `t.Skip` marker was added. | Ask what turned red. A skip added in the same PR as the change it stopped failing is the shape to look for. |
|
|
159
162
|
| `assertions-removed` | Assertions were commented out, or more left a file than entered it. | Compare against the issue's acceptance criteria: an assertion removed because the spec changed is fine, one removed because it failed is not. |
|
|
@@ -230,25 +233,52 @@ Keep the queue worth draining.
|
|
|
230
233
|
See **Reporting** below. That section is yours, and it is the only thing that
|
|
231
234
|
decides whether this tick ends in a message or in silence.
|
|
232
235
|
|
|
236
|
+
Whenever the reporting policy defers material outcomes to a digest, record each
|
|
237
|
+
ordinary outcome as soon as it happens:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
omp-conductor event record \
|
|
241
|
+
--category merge \
|
|
242
|
+
--summary "#42 merged" \
|
|
243
|
+
--evidence "https://github.com/acme/api/pull/42"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This command sends nothing. It writes the outcome to the durable digest ledger.
|
|
247
|
+
Use a short lowercase category, a one-line summary, and the issue, PR, release,
|
|
248
|
+
run, or URL that proves the result. Do not keep an outcome only in session memory
|
|
249
|
+
or in a Markdown scratch file. A later due-digest prompt lists the owed rows
|
|
250
|
+
with their ids. Include the rows you use by passing its printed `--events` and
|
|
251
|
+
`--notices` arguments to `omp-conductor report --kind digest`; omitted rows stay
|
|
252
|
+
owed. When policy permits material outcomes to interrupt, report them directly
|
|
253
|
+
instead.
|
|
254
|
+
|
|
233
255
|
*How* a report is delivered is not yours, and is not negotiable: run
|
|
234
|
-
`omp-conductor report --text "<the whole report>"` (add `--kind digest`
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
`
|
|
240
|
-
|
|
256
|
+
`omp-conductor report --text "<the whole report>"` (add `--kind digest` plus
|
|
257
|
+
the ledger row ids printed in the tick prompt for a digest). It persists the
|
|
258
|
+
text before anything is sent and prints a durable handoff id: a report id when
|
|
259
|
+
delivery is allowed, or a held-notice id until a digest or working-hours
|
|
260
|
+
catch-up claims it. The daemon owns delivery from there, and
|
|
261
|
+
`omp-conductor status` lists whatever it still owes.
|
|
262
|
+
Writing a report as end-of-turn text on a tick reaches nobody — that is how a suite release
|
|
263
|
+
and two tier-2 escalations went missing on 2026-08-06 — and `telegram_send`
|
|
264
|
+
reaches somebody but leaves no record that it did, so a report sent that way is
|
|
265
|
+
undetectable when it does not arrive.
|
|
241
266
|
|
|
242
267
|
A report is an update. It never contains a request: no "needs you" header, no
|
|
243
|
-
"let me know", no embedded options.
|
|
244
|
-
answer
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
268
|
+
"let me know", no embedded options. Use `telegram_ask` for each decision,
|
|
269
|
+
approval, or answer that you need. Include one sentence for the question, your
|
|
270
|
+
recommendation, and the options with their consequences. Mark the recommended
|
|
271
|
+
option. Batch several questions into one ask (the surface takes up to five);
|
|
272
|
+
never make one call per question, and never type a numbered menu into a plain
|
|
273
|
+
message. The tool shows each question on the terminal and Telegram, then accepts
|
|
274
|
+
the first answer from either surface. A returned answer proves an answer, not
|
|
275
|
+
Telegram delivery. If the question must demonstrably reach the operator through
|
|
276
|
+
Telegram, send it separately with `telegram_send` and prefix its text
|
|
277
|
+
`QUESTION:` so the autonomous-tick gate applies the decision category. Still
|
|
278
|
+
open a `decision` row for anything you ask: the ask collects the answer, and the
|
|
279
|
+
row stops it from being forgotten. In both directions, the delivery
|
|
280
|
+
contract is explicit: a message you did not explicitly send is a message that
|
|
281
|
+
did not arrive.
|
|
252
282
|
|
|
253
283
|
## Human messages
|
|
254
284
|
|
|
@@ -259,10 +289,11 @@ text you merely write reaches nobody — if you do not call `telegram_send`, the
|
|
|
259
289
|
person gets silence. While handling any turn, produce no visible commentary
|
|
260
290
|
between tool calls — reasoning stays in thinking, actions stay in tools.
|
|
261
291
|
|
|
262
|
-
If the answer needs a decision from the operator (a choice, a yes/no, an
|
|
263
|
-
approval), ask it with `telegram_ask
|
|
264
|
-
`telegram_send`, and never the generic `ask` UI.
|
|
265
|
-
|
|
292
|
+
If the answer needs a decision from the operator (a choice, a yes/no, or an
|
|
293
|
+
approval), ask it with `telegram_ask`. Never send numbered options through
|
|
294
|
+
`telegram_send`, and never use the generic `ask` UI. The tool returns the first
|
|
295
|
+
answer from the terminal or Telegram. A returned answer proves an answer, not
|
|
296
|
+
Telegram delivery. A cancelled or errored `telegram_ask` is not an answer.
|
|
266
297
|
|
|
267
298
|
A message may also reach you **mid-tick** (delivery is steering: it arrives
|
|
268
299
|
between two of your tool calls). Treat it as an interrupt, not a new tick:
|
|
@@ -280,6 +311,13 @@ arrived, and never batch the answer "for the report" — the person is waiting n
|
|
|
280
311
|
Promote rather than improvise. Escalating is a successful outcome; guessing is
|
|
281
312
|
not.
|
|
282
313
|
|
|
314
|
+
The tick prompt carries the runtime's mechanical operator-availability state.
|
|
315
|
+
Escalate tier 2 normally; do not infer working hours or bypass the configured
|
|
316
|
+
gate yourself. Outside the configured window, the daemon holds non-bypass
|
|
317
|
+
categories durably for the daily digest or one catch-up report when the window
|
|
318
|
+
opens. Configured bypass categories still page immediately. With no window,
|
|
319
|
+
interrupt behavior remains 24-hour.
|
|
320
|
+
|
|
283
321
|
## Hard boundaries
|
|
284
322
|
|
|
285
323
|
Not yours to relax:
|
|
@@ -385,9 +423,11 @@ Four controls stop different work:
|
|
|
385
423
|
spend a failure or continuation budget. Use it when the operator explicitly
|
|
386
424
|
ends obsolete or already-delivered work. A salvage failure keeps the tree and
|
|
387
425
|
names the path; recover that copy before any forced unblock.
|
|
388
|
-
- **Fleet dispatch:** `omp-conductor
|
|
389
|
-
mutations. Work admitted before
|
|
390
|
-
|
|
426
|
+
- **Fleet dispatch:** `omp-conductor hold` stops new claims and work-starting
|
|
427
|
+
mutations. Work admitted before it may still complete, and completion verbs and
|
|
428
|
+
releases remain available. Note it also disarms ticks, so it silences *you*:
|
|
429
|
+
stopping the fleet is the operator's call, and the right move is to report that
|
|
430
|
+
it needs stopping rather than to run this yourself.
|
|
391
431
|
- **Orchestrator ticks:** `omp-conductor disarm` removes the operator-owned
|
|
392
432
|
`ARMED` marker so ticks skip. It does not pause workers or stop processes.
|
|
393
433
|
|
|
@@ -458,12 +498,13 @@ The protocol, in order:
|
|
|
458
498
|
stand, then the lines you propose. A diff, not a description of one. This full
|
|
459
499
|
text is what you *apply* on a yes — it is not what you send.
|
|
460
500
|
2. **Ask, once — a single yes/no question, written for a phone.** Explicitly
|
|
461
|
-
call `telegram_ask`; never use the generic `ask` UI.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
501
|
+
call `telegram_ask`; never use the generic `ask` UI. The tool shows the
|
|
502
|
+
question on the terminal and Telegram, then returns the first answer from
|
|
503
|
+
either surface. A returned answer proves an answer, not Telegram delivery.
|
|
504
|
+
If the proposal must demonstrably reach the operator through Telegram, send
|
|
505
|
+
the compact question separately with `telegram_send`. If `telegram_ask` is
|
|
506
|
+
unavailable, send the same single yes/no question with `telegram_send`.
|
|
507
|
+
Wait for the operator's later reply, and never assume one. Telegram renders
|
|
467
508
|
none of your markdown, so asterisks and backticks arrive as literal characters:
|
|
468
509
|
- Lead with one plain sentence: what changes, and why, in your own words.
|
|
469
510
|
- Then show only the lines that actually change, compact, under two short
|
package/src/briefs/policy.md
CHANGED
|
@@ -52,46 +52,55 @@ propose the corrected steps.
|
|
|
52
52
|
|
|
53
53
|
## Project context (filled during onboarding)
|
|
54
54
|
|
|
55
|
-
Empty until
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
Empty until setup fills it in: the product in a paragraph, a map of which repo
|
|
56
|
+
owns what, the grooming guidance Duty 2 needs to judge priority and spot issues
|
|
57
|
+
that would collide, and where the roadmap lives. `omp-conductor setup` asks for
|
|
58
|
+
the last of those and proposes the rest.
|
|
59
59
|
|
|
60
60
|
## Reporting
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
62
|
+
The live report scope and availability window come from conductor config on
|
|
63
|
+
every tick; the tick's `Reporting` and `Operator availability` lines are
|
|
64
|
+
authoritative. All four scopes, spelled out:
|
|
65
|
+
|
|
66
|
+
- **`escalations`** — you speak when a human is needed, and otherwise wait for
|
|
67
|
+
the configured digest. That is: every tier-2 escalation when operator
|
|
68
|
+
availability permits, carrying the issue link and the single question; plus
|
|
69
|
+
a digest naming what merged, what is green and waiting on a merge, and what is
|
|
70
|
+
stuck and why.
|
|
71
|
+
- **`decisions`** — tier-2 escalations and fleet-stopping conditions interrupt
|
|
72
|
+
when operator availability permits; every other material event is held and
|
|
73
|
+
delivered in the configured digest instead of as its own ping.
|
|
72
74
|
- **`material`** — everything in `escalations`, plus each material event as it
|
|
73
|
-
happens: a run reaching a green PR (with
|
|
74
|
-
issue you pulled off the queue, a cap
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
happens when operator availability permits: a run reaching a green PR (with
|
|
76
|
+
the link), a run that failed twice, an issue you pulled off the queue, a cap
|
|
77
|
+
that stopped the fleet. Outside configured hours, non-bypass interruptions
|
|
78
|
+
wait for the next configured digest or opening. A tick where nothing changed
|
|
79
|
+
still says nothing.
|
|
80
|
+
- **`quiet`** — tier-2 escalations, fleet stops, and confirmed failures may
|
|
81
|
+
interrupt when operator availability permits; everything else waits for one
|
|
82
|
+
daily rollup.
|
|
83
|
+
|
|
84
|
+
**Delivery.** Never rely on end-of-turn text reaching anyone. The provable
|
|
85
|
+
delivery paths are `omp-conductor report` (reports — persisted and retried by
|
|
86
|
+
the daemon) and `telegram_send` (direct messages). `telegram_ask` is the decision
|
|
87
|
+
primitive, not Telegram delivery evidence. Everything else is noise or silence.
|
|
88
|
+
Hand every reportable event to the conductor's outbox:
|
|
82
89
|
|
|
83
90
|
```
|
|
84
91
|
omp-conductor report --text "<the whole report>" # a material event
|
|
85
92
|
omp-conductor report --text "<the whole digest>" --kind digest
|
|
86
93
|
```
|
|
87
94
|
|
|
88
|
-
The command persists the text *before* anything is sent and prints a
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
the
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
The command persists the text *before* anything is sent and prints a durable
|
|
96
|
+
handoff id. During quiet hours a material report becomes a held-notice id for
|
|
97
|
+
the next digest or working-hours catch-up; otherwise it becomes a report id and
|
|
98
|
+
the daemon retries delivery until it lands. Both survive you being compacted,
|
|
99
|
+
interrupted, or restarted mid-sentence. That is the difference between a report
|
|
100
|
+
and a claim about one: check for the handoff id, and never say something was
|
|
101
|
+
reported without it. Write plain text — Telegram renders none of your markdown,
|
|
102
|
+
so asterisks and backticks arrive as literal characters and a pasted section
|
|
103
|
+
becomes a wall.
|
|
95
104
|
|
|
96
105
|
The digest is at-most-once per day and the ledger decides that, not your memory:
|
|
97
106
|
a second `--kind digest` on the same day is refused and tells you which report
|
|
@@ -100,12 +109,15 @@ was lost mid-send is retried and arrives marked as a possible repeat; that is
|
|
|
100
109
|
deliberate, and a duplicate you can spot by its report id is the cheaper of the
|
|
101
110
|
two mistakes. `omp-conductor status` lists anything still undelivered.
|
|
102
111
|
|
|
103
|
-
`telegram_send` is still the right call for
|
|
104
|
-
an answer to their message, or a question of your own. It is not a
|
|
105
|
-
leaves no record that anything went out.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
`telegram_send` is still the right call for direct delivery to a person who is
|
|
113
|
+
waiting — an answer to their message, or a question of your own. It is not a
|
|
114
|
+
report: it leaves no record that anything went out. `telegram_ask` is the right
|
|
115
|
+
call for a decision. It shows the question on the terminal and Telegram, then
|
|
116
|
+
returns the first answer from either surface. A returned answer proves an
|
|
117
|
+
answer, not Telegram delivery. A cancelled or errored `telegram_ask` is not an
|
|
118
|
+
answer: re-deliver the question with `telegram_send`, prefixing its text
|
|
119
|
+
`QUESTION:` so an autonomous tick applies the decision category, or report the
|
|
120
|
+
channel as broken. It is never "asked once, no reply, dropped".
|
|
109
121
|
|
|
110
122
|
Reports never carry questions: anything needing an answer goes out as its own
|
|
111
123
|
ask, with a recommendation and options.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
You are reading one repository to answer a single question for an operator who is
|
|
2
|
+
configuring an automated dispatcher. You have been given
|
|
3
|
+
reading tools only: there is no shell, no editor and no verb here, so reading and
|
|
4
|
+
answering is the whole of what you can do.
|
|
5
|
+
|
|
6
|
+
## The question
|
|
7
|
+
|
|
8
|
+
What are the **pre-push gates** for `{{REPO}}` (branch `{{BRANCH}}`)? A pre-push
|
|
9
|
+
gate is a command a change must pass locally *before* it is pushed, and the answer
|
|
10
|
+
is only useful if it matches what CI actually runs.
|
|
11
|
+
|
|
12
|
+
## Where to look
|
|
13
|
+
|
|
14
|
+
In this order, stopping when you have enough:
|
|
15
|
+
|
|
16
|
+
1. CI workflow definitions — `.github/workflows/*.yml`, or the equivalent for
|
|
17
|
+
whatever CI this repo uses. This is the authority: it is what will fail a PR.
|
|
18
|
+
2. `package.json` scripts, `Makefile`, `justfile`, `Taskfile.yml`, `tox.ini`,
|
|
19
|
+
`pyproject.toml`, `Cargo.toml`.
|
|
20
|
+
3. Any `CONTRIBUTING.md` / `AGENTS.md` / `CLAUDE.md` that names a pre-push
|
|
21
|
+
routine.
|
|
22
|
+
|
|
23
|
+
## Three traps, each of which produces a wrong answer
|
|
24
|
+
|
|
25
|
+
- **Whole tree, not source directory.** If CI lints the repository and a script
|
|
26
|
+
lints only `src/`, the gate is the repository-wide command. A gate narrower than
|
|
27
|
+
CI is how a lint error reaches the runners with nobody watching.
|
|
28
|
+
- **Cheap only.** Gates run on a shared host, many at once. Include type checks,
|
|
29
|
+
lint and unit tests. **Exclude** docker builds, image builds, production builds,
|
|
30
|
+
browser/e2e suites, and anything that needs a service or a credential — CI owns
|
|
31
|
+
those.
|
|
32
|
+
- **An honest "no gates" is a real answer.** If this repo genuinely has no cheap
|
|
33
|
+
local check, say so with an empty list. Inventing a plausible `npm test` for a
|
|
34
|
+
repo that has no test script produces a gate that fails on every push.
|
|
35
|
+
|
|
36
|
+
Each gate needs the **`cwd` it runs from**, relative to the repository root, as a
|
|
37
|
+
monorepo's checks usually do not run from the top. Use `"."` for the root.
|
|
38
|
+
|
|
39
|
+
## Answer
|
|
40
|
+
|
|
41
|
+
One fenced JSON block, nothing else after it:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"gates": [{ "cmd": "bun run check", "cwd": "." }],
|
|
46
|
+
"evidence": "one line naming the files you read that establish these"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`evidence` is what lets the operator judge your answer without repeating your
|
|
51
|
+
reading. Name files, not impressions.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
You are reading one repository to draft a section of an operator's standing brief.
|
|
2
|
+
You have been given reading tools only: there is no shell,
|
|
3
|
+
no editor and no verb here, so reading and answering is the whole of what you can
|
|
4
|
+
do.
|
|
5
|
+
|
|
6
|
+
## What you are writing
|
|
7
|
+
|
|
8
|
+
The `## Project context` section of `POLICY.md`, which a supervising orchestrator
|
|
9
|
+
session re-reads on **every** tick. Keep it **under 40 lines**: a brief nobody
|
|
10
|
+
finishes reading is a brief that gets skimmed.
|
|
11
|
+
|
|
12
|
+
It needs exactly four things, in this order.
|
|
13
|
+
|
|
14
|
+
1. **The product in one paragraph.** What it does, for whom. Not a feature list.
|
|
15
|
+
2. **A repo map** — one line per routing key below, naming what that repo owns in
|
|
16
|
+
the operator's vocabulary. This is what turns a routing label into a judgement.
|
|
17
|
+
3. **Grooming guidance.** Which repos ship together, so a change in one is known
|
|
18
|
+
to need a matching PR in the other. And which *kinds* of issue touch the same
|
|
19
|
+
files: those must not be queued concurrently, because two agents editing one
|
|
20
|
+
file produce two pull requests that cannot both merge.
|
|
21
|
+
4. **Where the roadmap lives, and how to judge priority against it** — in one
|
|
22
|
+
line.
|
|
23
|
+
|
|
24
|
+
## Where you are
|
|
25
|
+
|
|
26
|
+
Your working directory holds **one directory per routing repo**, each a shallow
|
|
27
|
+
clone, listed below. Read across all of them — that is what makes items 2 and 3
|
|
28
|
+
answerable, and a draft written from one repo has to guess the rest.
|
|
29
|
+
|
|
30
|
+
In each, read the `README` (what it is for, and who uses it), whatever top-level
|
|
31
|
+
architecture doc exists (`docs/`, `ARCHITECTURE.md`, an ADR directory) for which
|
|
32
|
+
repo owns which concern, and `AGENTS.md` / `CONTRIBUTING.md` for the repo's own
|
|
33
|
+
rules — those outrank the brief.
|
|
34
|
+
|
|
35
|
+
## Stated inputs — these are given, not yours to decide
|
|
36
|
+
|
|
37
|
+
The routing keys this project dispatches to, and the directory each was cloned
|
|
38
|
+
into. Refer to repos by their **routing key**, never by the directory name — the
|
|
39
|
+
directory is scratch, the key is what an issue carries:
|
|
40
|
+
|
|
41
|
+
{{REPOS}}
|
|
42
|
+
|
|
43
|
+
The operator's own answer about the roadmap and the current priority:
|
|
44
|
+
|
|
45
|
+
> {{ROADMAP}}
|
|
46
|
+
|
|
47
|
+
**Item 4 must quote that answer.** You are reading a repository, which shows what
|
|
48
|
+
is open and never what matters; the operator supplied the ranking and it is not
|
|
49
|
+
yours to improve. If the quote above is empty, write item 4 as a single line
|
|
50
|
+
saying the operator has not named a roadmap yet, and nothing more.
|
|
51
|
+
|
|
52
|
+
## Answer
|
|
53
|
+
|
|
54
|
+
One fenced markdown block containing the section and nothing else — start it at
|
|
55
|
+
the `## Project context` heading. No preamble, no commentary after it.
|
|
56
|
+
|
|
57
|
+
Write what the repository supports. Where you are guessing, say so in the text
|
|
58
|
+
itself in a few words: the operator edits this file, and a marked guess is one
|
|
59
|
+
they can correct, while a confident invention is one they will not notice.
|