omp-conductor 0.13.0 → 0.14.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 +214 -66
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/briefs/orchestrator.md +63 -26
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +122 -14
- package/src/config.ts +113 -5
- package/src/daemon.ts +218 -32
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/fleet.ts +34 -3
- package/src/orchestrator-tick.ts +437 -20
- package/src/plugin.ts +138 -12
- package/src/reports.ts +202 -5
- package/src/setup.ts +193 -33
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +151 -12
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +8 -9
- package/src/worker.ts +18 -6
|
@@ -154,6 +154,7 @@ to notice it unaided.
|
|
|
154
154
|
| `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
155
|
| `changed-line-missing` | The report disclosed nothing at all. | Read the diff before merging; you have no summary of it. |
|
|
156
156
|
| `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. |
|
|
157
|
+
| `report-format-unparsed` | The audit could not parse the report's format. | Not a trust signal against the worker — read the diff directly. |
|
|
157
158
|
| `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
159
|
| `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
160
|
| `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 +231,52 @@ Keep the queue worth draining.
|
|
|
230
231
|
See **Reporting** below. That section is yours, and it is the only thing that
|
|
231
232
|
decides whether this tick ends in a message or in silence.
|
|
232
233
|
|
|
234
|
+
Whenever the reporting policy defers material outcomes to a digest, record each
|
|
235
|
+
ordinary outcome as soon as it happens:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
omp-conductor event record \
|
|
239
|
+
--category merge \
|
|
240
|
+
--summary "#42 merged" \
|
|
241
|
+
--evidence "https://github.com/acme/api/pull/42"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
This command sends nothing. It writes the outcome to the durable digest ledger.
|
|
245
|
+
Use a short lowercase category, a one-line summary, and the issue, PR, release,
|
|
246
|
+
run, or URL that proves the result. Do not keep an outcome only in session memory
|
|
247
|
+
or in a Markdown scratch file. A later due-digest prompt lists the owed rows
|
|
248
|
+
with their ids. Include the rows you use by passing its printed `--events` and
|
|
249
|
+
`--notices` arguments to `omp-conductor report --kind digest`; omitted rows stay
|
|
250
|
+
owed. When policy permits material outcomes to interrupt, report them directly
|
|
251
|
+
instead.
|
|
252
|
+
|
|
233
253
|
*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
|
-
|
|
254
|
+
`omp-conductor report --text "<the whole report>"` (add `--kind digest` plus
|
|
255
|
+
the ledger row ids printed in the tick prompt for a digest). It persists the
|
|
256
|
+
text before anything is sent and prints a durable handoff id: a report id when
|
|
257
|
+
delivery is allowed, or a held-notice id until a digest or working-hours
|
|
258
|
+
catch-up claims it. The daemon owns delivery from there, and
|
|
259
|
+
`omp-conductor status` lists whatever it still owes.
|
|
260
|
+
Writing a report as end-of-turn text on a tick reaches nobody — that is how a suite release
|
|
261
|
+
and two tier-2 escalations went missing on 2026-08-06 — and `telegram_send`
|
|
262
|
+
reaches somebody but leaves no record that it did, so a report sent that way is
|
|
263
|
+
undetectable when it does not arrive.
|
|
241
264
|
|
|
242
265
|
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
|
-
|
|
266
|
+
"let me know", no embedded options. Use `telegram_ask` for each decision,
|
|
267
|
+
approval, or answer that you need. Include one sentence for the question, your
|
|
268
|
+
recommendation, and the options with their consequences. Mark the recommended
|
|
269
|
+
option. Batch several questions into one ask (the surface takes up to five);
|
|
270
|
+
never make one call per question, and never type a numbered menu into a plain
|
|
271
|
+
message. The tool shows each question on the terminal and Telegram, then accepts
|
|
272
|
+
the first answer from either surface. A returned answer proves an answer, not
|
|
273
|
+
Telegram delivery. If the question must demonstrably reach the operator through
|
|
274
|
+
Telegram, send it separately with `telegram_send` and prefix its text
|
|
275
|
+
`QUESTION:` so the autonomous-tick gate applies the decision category. Still
|
|
276
|
+
open a `decision` row for anything you ask: the ask collects the answer, and the
|
|
277
|
+
row stops it from being forgotten. In both directions, the delivery
|
|
278
|
+
contract is explicit: a message you did not explicitly send is a message that
|
|
279
|
+
did not arrive.
|
|
252
280
|
|
|
253
281
|
## Human messages
|
|
254
282
|
|
|
@@ -259,10 +287,11 @@ text you merely write reaches nobody — if you do not call `telegram_send`, the
|
|
|
259
287
|
person gets silence. While handling any turn, produce no visible commentary
|
|
260
288
|
between tool calls — reasoning stays in thinking, actions stay in tools.
|
|
261
289
|
|
|
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
|
-
|
|
290
|
+
If the answer needs a decision from the operator (a choice, a yes/no, or an
|
|
291
|
+
approval), ask it with `telegram_ask`. Never send numbered options through
|
|
292
|
+
`telegram_send`, and never use the generic `ask` UI. The tool returns the first
|
|
293
|
+
answer from the terminal or Telegram. A returned answer proves an answer, not
|
|
294
|
+
Telegram delivery. A cancelled or errored `telegram_ask` is not an answer.
|
|
266
295
|
|
|
267
296
|
A message may also reach you **mid-tick** (delivery is steering: it arrives
|
|
268
297
|
between two of your tool calls). Treat it as an interrupt, not a new tick:
|
|
@@ -280,6 +309,13 @@ arrived, and never batch the answer "for the report" — the person is waiting n
|
|
|
280
309
|
Promote rather than improvise. Escalating is a successful outcome; guessing is
|
|
281
310
|
not.
|
|
282
311
|
|
|
312
|
+
The tick prompt carries the runtime's mechanical operator-availability state.
|
|
313
|
+
Escalate tier 2 normally; do not infer working hours or bypass the configured
|
|
314
|
+
gate yourself. Outside the configured window, the daemon holds non-bypass
|
|
315
|
+
categories durably for the daily digest or one catch-up report when the window
|
|
316
|
+
opens. Configured bypass categories still page immediately. With no window,
|
|
317
|
+
interrupt behavior remains 24-hour.
|
|
318
|
+
|
|
283
319
|
## Hard boundaries
|
|
284
320
|
|
|
285
321
|
Not yours to relax:
|
|
@@ -458,12 +494,13 @@ The protocol, in order:
|
|
|
458
494
|
stand, then the lines you propose. A diff, not a description of one. This full
|
|
459
495
|
text is what you *apply* on a yes — it is not what you send.
|
|
460
496
|
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
|
-
|
|
497
|
+
call `telegram_ask`; never use the generic `ask` UI. The tool shows the
|
|
498
|
+
question on the terminal and Telegram, then returns the first answer from
|
|
499
|
+
either surface. A returned answer proves an answer, not Telegram delivery.
|
|
500
|
+
If the proposal must demonstrably reach the operator through Telegram, send
|
|
501
|
+
the compact question separately with `telegram_send`. If `telegram_ask` is
|
|
502
|
+
unavailable, send the same single yes/no question with `telegram_send`.
|
|
503
|
+
Wait for the operator's later reply, and never assume one. Telegram renders
|
|
467
504
|
none of your markdown, so asterisks and backticks arrive as literal characters:
|
|
468
505
|
- Lead with one plain sentence: what changes, and why, in your own words.
|
|
469
506
|
- Then show only the lines that actually change, compact, under two short
|
package/src/briefs/policy.md
CHANGED
|
@@ -59,39 +59,48 @@ and spot issues that would collide. Ask an omp session to read
|
|
|
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.
|
package/src/cli.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
|
|
6
6
|
* cannot drift apart.
|
|
7
7
|
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
9
10
|
import { userInfo } from "node:os";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
proposeRetrofit,
|
|
22
23
|
repairPolicyBannerCrumbs,
|
|
23
24
|
} from "./brief-upgrade.ts";
|
|
25
|
+
import { interruptDisposition } from "./availability.ts";
|
|
24
26
|
import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
25
27
|
import { CONDITION_FORMS, parseCondition } from "./decisions.ts";
|
|
26
28
|
import { isPaused, pausedAt, runDaemon, setPaused } from "./daemon.ts";
|
|
@@ -115,7 +117,8 @@ usage:
|
|
|
115
117
|
omp-conductor graph-setup [--project NAME] [--write]
|
|
116
118
|
omp-conductor brief-upgrade [--migrate|--retrofit] [--apply] [--file PATH] [--project NAME]
|
|
117
119
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
118
|
-
omp-conductor
|
|
120
|
+
omp-conductor event record --category NAME --summary TEXT --evidence REF [--occurred-at ISO] [--project NAME]
|
|
121
|
+
omp-conductor report --text TEXT [--kind material|digest] [--events IDS] [--notices IDS] [--project NAME]
|
|
119
122
|
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
120
123
|
omp-conductor decision resolve <id> --answer TEXT [--project NAME]
|
|
121
124
|
omp-conductor decision withdraw <id> [--reason TEXT] [--project NAME]
|
|
@@ -197,12 +200,17 @@ usage:
|
|
|
197
200
|
strings, one per --arg (e.g. --arg prUrl=https://x --arg headSha=y).
|
|
198
201
|
A refusal exits 3. See conductor_pr_merge/conductor_label/
|
|
199
202
|
conductor_release/conductor_pr_update_branch in the brief.
|
|
203
|
+
event persist one ordinary material outcome without sending it. Category
|
|
204
|
+
is a short lowercase slug; summary and evidence name what happened
|
|
205
|
+
and where it can be verified. --occurred-at defaults to now.
|
|
200
206
|
report hand a rendered report to the daemon's durable outbox. The report is
|
|
201
207
|
persisted before anything is sent, delivered by the daemon with
|
|
202
208
|
bounded retries, and shown by status until it lands. Delivery is
|
|
203
209
|
at-least-once: a crash mid-send is retried and the retry says it may
|
|
204
210
|
be a repeat. --kind digest is accepted at most once per local day,
|
|
205
211
|
decided from the ledger rather than from what you remember sending.
|
|
212
|
+
A digest associates the comma-separated --events and --notices rows
|
|
213
|
+
atomically; omitted rows stay owed.
|
|
206
214
|
decision record, list and close the questions you have put to your operator.
|
|
207
215
|
A question that lives only in a session's context is lost to the next
|
|
208
216
|
compaction, so \`decision open\` writes it down and every tick's prompt
|
|
@@ -265,6 +273,26 @@ function flag(argv: string[], name: string): string | undefined {
|
|
|
265
273
|
return prefixed?.slice(name.length + 3);
|
|
266
274
|
}
|
|
267
275
|
|
|
276
|
+
function digestIdsFlag(argv: string[], name: "events" | "notices"): string[] {
|
|
277
|
+
const raw = flag(argv, name);
|
|
278
|
+
if (raw === undefined) {
|
|
279
|
+
if (argv.includes(`--${name}`)) {
|
|
280
|
+
process.stderr.write(`omp-conductor: report --${name} needs comma-separated row ids\n`);
|
|
281
|
+
process.exit(2);
|
|
282
|
+
}
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const ids = [...new Set(raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0))];
|
|
286
|
+
if (
|
|
287
|
+
ids.length === 0 ||
|
|
288
|
+
ids.some((id) => !/^(?:[0-9a-f]{12}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(id))
|
|
289
|
+
) {
|
|
290
|
+
process.stderr.write(`omp-conductor: report --${name} needs comma-separated ledger row ids\n`);
|
|
291
|
+
process.exit(2);
|
|
292
|
+
}
|
|
293
|
+
return ids;
|
|
294
|
+
}
|
|
295
|
+
|
|
268
296
|
/**
|
|
269
297
|
* `--port` for the three verbs that take one. Exits 2 rather than defaulting,
|
|
270
298
|
* because silently ignoring a typo'd port would leave the operator probing an
|
|
@@ -1019,6 +1047,58 @@ try {
|
|
|
1019
1047
|
break;
|
|
1020
1048
|
}
|
|
1021
1049
|
|
|
1050
|
+
case "event": {
|
|
1051
|
+
if (argv[1] !== "record") {
|
|
1052
|
+
process.stderr.write("omp-conductor: event needs the record subcommand\n");
|
|
1053
|
+
process.exit(2);
|
|
1054
|
+
}
|
|
1055
|
+
const category = flag(argv, "category");
|
|
1056
|
+
const summary = flag(argv, "summary")?.replace(/\s+/g, " ").trim();
|
|
1057
|
+
const evidence = flag(argv, "evidence")?.replace(/\s+/g, " ").trim();
|
|
1058
|
+
if (
|
|
1059
|
+
category === undefined ||
|
|
1060
|
+
!/^[a-z0-9][a-z0-9-]{0,31}$/.test(category) ||
|
|
1061
|
+
summary === undefined ||
|
|
1062
|
+
summary.length === 0 ||
|
|
1063
|
+
summary.length > 240 ||
|
|
1064
|
+
summary.startsWith("--") ||
|
|
1065
|
+
evidence === undefined ||
|
|
1066
|
+
evidence.length === 0 ||
|
|
1067
|
+
evidence.length > 500 ||
|
|
1068
|
+
evidence.startsWith("--")
|
|
1069
|
+
) {
|
|
1070
|
+
process.stderr.write(
|
|
1071
|
+
"omp-conductor: event record needs --category with a lowercase slug, --summary (1-240 chars), and --evidence (1-500 chars)\n",
|
|
1072
|
+
);
|
|
1073
|
+
process.exit(2);
|
|
1074
|
+
}
|
|
1075
|
+
const rawOccurredAt = flag(argv, "occurred-at");
|
|
1076
|
+
const recordedAt = Date.now();
|
|
1077
|
+
const occurredAt = rawOccurredAt === undefined ? recordedAt : Date.parse(rawOccurredAt);
|
|
1078
|
+
if (!Number.isFinite(occurredAt)) {
|
|
1079
|
+
process.stderr.write("omp-conductor: event record --occurred-at needs an ISO timestamp\n");
|
|
1080
|
+
process.exit(2);
|
|
1081
|
+
}
|
|
1082
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
1083
|
+
const store = openStore(dbPath());
|
|
1084
|
+
try {
|
|
1085
|
+
const event = store.recordMaterialEvent({
|
|
1086
|
+
project: project.name,
|
|
1087
|
+
category,
|
|
1088
|
+
summary,
|
|
1089
|
+
evidence,
|
|
1090
|
+
occurredAt,
|
|
1091
|
+
recordedAt,
|
|
1092
|
+
});
|
|
1093
|
+
process.stdout.write(
|
|
1094
|
+
`event ${event.id} recorded for ${project.name} (${event.category}, ${new Date(event.occurredAt).toISOString()}) — nothing sent\n`,
|
|
1095
|
+
);
|
|
1096
|
+
} finally {
|
|
1097
|
+
store.close();
|
|
1098
|
+
}
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1022
1102
|
/**
|
|
1023
1103
|
* The handover point. Authorship stays with the model; from here the daemon
|
|
1024
1104
|
* owns delivery, so "I sent the report" stops being a claim the model makes
|
|
@@ -1043,6 +1123,12 @@ try {
|
|
|
1043
1123
|
process.exit(2);
|
|
1044
1124
|
}
|
|
1045
1125
|
const kind = rawKind as ReportKind;
|
|
1126
|
+
if (kind !== "digest" && (argv.includes("--events") || argv.includes("--notices"))) {
|
|
1127
|
+
process.stderr.write(
|
|
1128
|
+
"omp-conductor: report --events and --notices are valid only with --kind digest\n",
|
|
1129
|
+
);
|
|
1130
|
+
process.exit(2);
|
|
1131
|
+
}
|
|
1046
1132
|
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
1047
1133
|
const store = openStore(dbPath());
|
|
1048
1134
|
try {
|
|
@@ -1051,8 +1137,8 @@ try {
|
|
|
1051
1137
|
// policy defers is refused here rather than silently turning into a
|
|
1052
1138
|
// page, or a digest going out off-schedule.
|
|
1053
1139
|
const policy = project.reporting;
|
|
1140
|
+
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
1054
1141
|
if (kind === "digest") {
|
|
1055
|
-
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
1056
1142
|
if (digestPolicy.cadence === "none") {
|
|
1057
1143
|
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
1058
1144
|
process.exit(2);
|
|
@@ -1070,32 +1156,54 @@ try {
|
|
|
1070
1156
|
}
|
|
1071
1157
|
}
|
|
1072
1158
|
if (kind === "material") {
|
|
1073
|
-
const
|
|
1074
|
-
if (
|
|
1159
|
+
const disposition = interruptDisposition(policy, "material", at);
|
|
1160
|
+
if (disposition === "digest") {
|
|
1075
1161
|
process.stderr.write(
|
|
1076
1162
|
"omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
|
|
1077
1163
|
);
|
|
1078
1164
|
process.exit(2);
|
|
1079
1165
|
}
|
|
1166
|
+
if (disposition === "availability") {
|
|
1167
|
+
const noticeId = randomUUID();
|
|
1168
|
+
store.addHeldNotice({
|
|
1169
|
+
id: noticeId,
|
|
1170
|
+
project: project.name,
|
|
1171
|
+
category: "material",
|
|
1172
|
+
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
1173
|
+
detail: body,
|
|
1174
|
+
createdAt: at,
|
|
1175
|
+
releaseOnAvailable: true,
|
|
1176
|
+
});
|
|
1177
|
+
process.stdout.write(
|
|
1178
|
+
`held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
|
|
1179
|
+
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
1180
|
+
);
|
|
1181
|
+
break;
|
|
1182
|
+
}
|
|
1080
1183
|
}
|
|
1081
|
-
const
|
|
1184
|
+
const draft = {
|
|
1082
1185
|
project: project.name,
|
|
1083
1186
|
kind,
|
|
1084
1187
|
body,
|
|
1085
|
-
// Only
|
|
1086
|
-
//
|
|
1087
|
-
...(kind === "digest"
|
|
1088
|
-
? { dedupeKey: digestDedupeKey(at,
|
|
1188
|
+
// Only a daily digest is at-most-once. Per-tick digests and material
|
|
1189
|
+
// reports describe new outcomes each time, so they carry no daily key.
|
|
1190
|
+
...(kind === "digest" && digestPolicy.cadence === "daily"
|
|
1191
|
+
? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
|
|
1089
1192
|
: {}),
|
|
1090
1193
|
at,
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1194
|
+
};
|
|
1195
|
+
const { report, deduped } =
|
|
1196
|
+
kind === "digest"
|
|
1197
|
+
? store.enqueueDigestReport(
|
|
1198
|
+
draft,
|
|
1199
|
+
digestIdsFlag(argv, "events"),
|
|
1200
|
+
digestIdsFlag(argv, "notices"),
|
|
1201
|
+
)
|
|
1202
|
+
: store.enqueueReport(draft);
|
|
1095
1203
|
process.stdout.write(
|
|
1096
1204
|
deduped
|
|
1097
1205
|
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|
|
1098
|
-
"the ledger decides this, not your memory of the last tick;
|
|
1206
|
+
"the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
|
|
1099
1207
|
: `report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
1100
1208
|
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
1101
1209
|
);
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
REPORT_SCOPES,
|
|
38
38
|
INTERRUPT_CATEGORIES,
|
|
39
39
|
DIGEST_CADENCES,
|
|
40
|
+
WEEKDAYS,
|
|
40
41
|
type Caps,
|
|
41
42
|
type ConductorConfig,
|
|
42
43
|
type DigestCadence,
|
|
@@ -49,6 +50,8 @@ import {
|
|
|
49
50
|
type ReleaseRequirement,
|
|
50
51
|
type ReportScope,
|
|
51
52
|
type ReportingPolicy,
|
|
53
|
+
type Weekday,
|
|
54
|
+
type WeeklyAvailability,
|
|
52
55
|
type RepoTarget,
|
|
53
56
|
type ResolvedGrants,
|
|
54
57
|
} from "./types.ts";
|
|
@@ -72,6 +75,7 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
|
72
75
|
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
73
76
|
const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
|
|
74
77
|
const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
|
|
78
|
+
const WEEKDAY_LIST = quoteList(WEEKDAYS);
|
|
75
79
|
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
76
80
|
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
77
81
|
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
@@ -544,15 +548,17 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
|
|
|
544
548
|
const keys = Object.keys(raw);
|
|
545
549
|
// `scopePreset` is written by a fully-normalised policy (the setup wizard
|
|
546
550
|
// saves presets materialised); `scope` is the legacy form. Both are known.
|
|
547
|
-
const known = ["scope", "interruptOn", "digest", "scopePreset"];
|
|
551
|
+
const known = ["scope", "interruptOn", "digest", "availability", "scopePreset"];
|
|
548
552
|
const unknownKeys = keys.filter((k) => !known.includes(k));
|
|
549
553
|
if (unknownKeys.length > 0) {
|
|
550
554
|
problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
551
555
|
}
|
|
552
556
|
const hasScope = raw["scope"] !== undefined;
|
|
553
|
-
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
|
|
557
|
+
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest") || keys.includes("availability");
|
|
554
558
|
if (hasScope && hasExplicit) {
|
|
555
|
-
problems.push(
|
|
559
|
+
problems.push(
|
|
560
|
+
`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
|
|
561
|
+
);
|
|
556
562
|
return defaultReporting();
|
|
557
563
|
}
|
|
558
564
|
if (hasScope || (!hasExplicit && keys.length === 0)) {
|
|
@@ -581,9 +587,23 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
|
|
|
581
587
|
? (storedPreset as ReportScope)
|
|
582
588
|
: undefined;
|
|
583
589
|
|
|
590
|
+
const interruptOn = normalizeInterruptOn(raw["interruptOn"], label, problems);
|
|
591
|
+
const digest = normalizeDigest(raw["digest"], label, problems);
|
|
592
|
+
const availability = normalizeAvailability(raw["availability"], label, problems);
|
|
593
|
+
// One setup answer supplies both clocks. Hand-written configs may omit the
|
|
594
|
+
// digest copy; the availability zone then becomes its daily clock too.
|
|
595
|
+
if (availability !== undefined && digest.cadence === "daily") {
|
|
596
|
+
if (digest.timezone === undefined) digest.timezone = availability.timezone;
|
|
597
|
+
else if (digest.timezone !== availability.timezone) {
|
|
598
|
+
problems.push(
|
|
599
|
+
`${label}: reporting.digest.timezone must match reporting.availability.timezone`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
584
603
|
return {
|
|
585
|
-
interruptOn
|
|
586
|
-
digest
|
|
604
|
+
interruptOn,
|
|
605
|
+
digest,
|
|
606
|
+
...(availability === undefined ? {} : { availability }),
|
|
587
607
|
...(scopePreset === undefined ? {} : { scopePreset }),
|
|
588
608
|
};
|
|
589
609
|
}
|
|
@@ -665,6 +685,94 @@ function normalizeDigest(parsed: unknown, label: string, problems: string[]): Re
|
|
|
665
685
|
return digest;
|
|
666
686
|
}
|
|
667
687
|
|
|
688
|
+
function normalizeAvailability(
|
|
689
|
+
parsed: unknown,
|
|
690
|
+
label: string,
|
|
691
|
+
problems: string[],
|
|
692
|
+
): WeeklyAvailability | undefined {
|
|
693
|
+
if (parsed === undefined) return undefined;
|
|
694
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
695
|
+
problems.push(`${label}: reporting.availability must be an object`);
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
const raw = parsed as Raw;
|
|
699
|
+
const unknownKeys = Object.keys(raw).filter(
|
|
700
|
+
(key) => !["timezone", "days", "start", "end", "bypass"].includes(key),
|
|
701
|
+
);
|
|
702
|
+
if (unknownKeys.length > 0) {
|
|
703
|
+
problems.push(`${label}: reporting.availability has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let timezone: string | undefined;
|
|
707
|
+
if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") {
|
|
708
|
+
problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
|
|
709
|
+
} else {
|
|
710
|
+
try {
|
|
711
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
712
|
+
timezone = raw["timezone"];
|
|
713
|
+
} catch {
|
|
714
|
+
problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const days: Weekday[] = [];
|
|
719
|
+
if (!Array.isArray(raw["days"]) || raw["days"].length === 0) {
|
|
720
|
+
problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
|
|
721
|
+
} else {
|
|
722
|
+
for (const item of raw["days"]) {
|
|
723
|
+
if (typeof item !== "string" || !(WEEKDAYS as readonly string[]).includes(item)) {
|
|
724
|
+
problems.push(
|
|
725
|
+
`${label}: reporting.availability.days has unknown day ${JSON.stringify(item)} — one of ${WEEKDAY_LIST}`,
|
|
726
|
+
);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const day = item as Weekday;
|
|
730
|
+
if (!days.includes(day)) days.push(day);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const start = raw["start"];
|
|
735
|
+
const end = raw["end"];
|
|
736
|
+
if (typeof start !== "string" || !DIGEST_AT.test(start)) {
|
|
737
|
+
problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
|
|
738
|
+
}
|
|
739
|
+
if (typeof end !== "string" || !DIGEST_AT.test(end)) {
|
|
740
|
+
problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
|
|
741
|
+
}
|
|
742
|
+
if (typeof start === "string" && typeof end === "string" && start === end) {
|
|
743
|
+
problems.push(`${label}: reporting.availability.start and end must differ`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const bypass: InterruptCategory[] = [];
|
|
747
|
+
if (!Array.isArray(raw["bypass"])) {
|
|
748
|
+
problems.push(
|
|
749
|
+
`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`,
|
|
750
|
+
);
|
|
751
|
+
} else {
|
|
752
|
+
for (const item of raw["bypass"]) {
|
|
753
|
+
if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
|
|
754
|
+
problems.push(
|
|
755
|
+
`${label}: reporting.availability.bypass has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`,
|
|
756
|
+
);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const category = item as InterruptCategory;
|
|
760
|
+
if (!bypass.includes(category)) bypass.push(category);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return timezone === undefined ||
|
|
765
|
+
days.length === 0 ||
|
|
766
|
+
typeof start !== "string" ||
|
|
767
|
+
!DIGEST_AT.test(start) ||
|
|
768
|
+
typeof end !== "string" ||
|
|
769
|
+
!DIGEST_AT.test(end) ||
|
|
770
|
+
start === end ||
|
|
771
|
+
!Array.isArray(raw["bypass"])
|
|
772
|
+
? undefined
|
|
773
|
+
: { timezone, days, start, end, bypass };
|
|
774
|
+
}
|
|
775
|
+
|
|
668
776
|
/**
|
|
669
777
|
* Who triages escalations, and how they are delivered when nobody answers.
|
|
670
778
|
*
|