omp-conductor 0.17.0 → 0.18.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/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/ask.ts
CHANGED
|
@@ -93,7 +93,11 @@ export interface AskRequest {
|
|
|
93
93
|
timeoutSeconds?: number;
|
|
94
94
|
/** What is waiting on the answer, for the decision row and the digest. */
|
|
95
95
|
blocks?: string;
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The option applied by `auto-proceed`; required exactly for that outcome.
|
|
98
|
+
* When {@link AskRequest.options} are supplied it must name one of their
|
|
99
|
+
* labels — the label as delivered, never an index.
|
|
100
|
+
*/
|
|
97
101
|
recommended?: string;
|
|
98
102
|
/** The choices shown to the operator, with {@link AskRequest.recommended} named. */
|
|
99
103
|
options?: AskOption[];
|
|
@@ -115,6 +119,13 @@ function isInterruptCategory(value: unknown): value is InterruptCategory {
|
|
|
115
119
|
* {@link MAX_QUESTION_LINES} / {@link MAX_QUESTION_CHARACTERS}, and refuses
|
|
116
120
|
* rather than truncating: a truncated question reads as complete and has lost
|
|
117
121
|
* its options, which is strictly worse than a rejection the caller must fix.
|
|
122
|
+
*
|
|
123
|
+
* The refusal is terminal for the call (#741): nothing is recorded or
|
|
124
|
+
* delivered, and the caller must not resubmit the trimmed question in the same
|
|
125
|
+
* turn — the rationale belongs in the issue or the event ledger, and a
|
|
126
|
+
* decision that still needs a human answer is raised as a fresh ask on a later
|
|
127
|
+
* tick. A re-issued ask therefore always starts from the accepted arguments of
|
|
128
|
+
* a new call, never from a rewrite of a refused one.
|
|
118
129
|
*/
|
|
119
130
|
export function validateQuestionShape(question: string): QuestionShape {
|
|
120
131
|
const lines = question.split("\n").length;
|
|
@@ -151,7 +162,13 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
151
162
|
}
|
|
152
163
|
const shape = validateQuestionShape(question.trim());
|
|
153
164
|
if (!shape.ok) {
|
|
154
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
problem:
|
|
168
|
+
`conductor_ask ${shape.problem} Do not re-issue the trimmed question in this turn: if the ` +
|
|
169
|
+
"decision still needs a human answer, say so in the report and raise it as a fresh ask in a " +
|
|
170
|
+
"later tick.",
|
|
171
|
+
};
|
|
155
172
|
}
|
|
156
173
|
|
|
157
174
|
const onTimeoutRaw = input["on-timeout"];
|
|
@@ -225,6 +242,25 @@ export function parseAskRequest(raw: unknown): AskParse {
|
|
|
225
242
|
options = parsedOptions;
|
|
226
243
|
}
|
|
227
244
|
|
|
245
|
+
// A recommendation must name a supplied option. `recommended` is the option
|
|
246
|
+
// label exactly as the operator reads it — never an index (that is
|
|
247
|
+
// telegram_ask's convention, not this tool's) and never free text when a
|
|
248
|
+
// menu exists. Refused at parse time so the incoherent ask leaves no trace:
|
|
249
|
+
// on `auto-proceed` its text becomes the recorded auto-applied answer, and a
|
|
250
|
+
// value that named no option would resolve a decision row to something that
|
|
251
|
+
// was never a choice (#740).
|
|
252
|
+
if (options !== undefined && recommended !== undefined) {
|
|
253
|
+
const labels = options.map((option) => option.label);
|
|
254
|
+
if (!labels.includes(recommended)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
problem:
|
|
258
|
+
`conductor_ask recommended "${recommended}" names no option — it must be one of: ${labels.join(", ")}. ` +
|
|
259
|
+
"It is the option label exactly as delivered to the operator, not an index",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
228
264
|
const categoryRaw = input["category"];
|
|
229
265
|
if (categoryRaw !== undefined && !isInterruptCategory(categoryRaw)) {
|
|
230
266
|
return {
|
|
@@ -274,7 +310,7 @@ export function askParameterSchema(): Record<string, unknown> {
|
|
|
274
310
|
recommended: {
|
|
275
311
|
type: "string",
|
|
276
312
|
description:
|
|
277
|
-
"The option applied on auto-proceed
|
|
313
|
+
"The option applied on auto-proceed; required when on-timeout is auto-proceed because the row must record what was auto-applied. When options are supplied this must be one of their labels — the label as delivered to the operator, not an index.",
|
|
278
314
|
},
|
|
279
315
|
blocks: {
|
|
280
316
|
type: "string",
|
package/src/availability.ts
CHANGED
|
@@ -91,17 +91,35 @@ export function availabilityDisposition(
|
|
|
91
91
|
return "availability";
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** A question-kind category. Every ask surface — `message --category
|
|
95
|
+
* decision-needed`, a `QUESTION:`-marked message, and `conductor_ask`'s
|
|
96
|
+
* default — resolves to this one category, so treating it as
|
|
97
|
+
* availability-deferred covers the whole question pathway at once (#596). */
|
|
98
|
+
export const QUESTION_KIND: InterruptCategory = "decision-needed";
|
|
99
|
+
|
|
94
100
|
/**
|
|
95
101
|
* Decide one tier-2 category. `digest` means the category policy itself defers
|
|
96
102
|
* it; `availability` means it was otherwise interruptible and may be released
|
|
97
103
|
* when the configured window next opens.
|
|
104
|
+
*
|
|
105
|
+
* A question is availability-deferred, never digest-deferred (#596): the
|
|
106
|
+
* category policy decides what may interrupt the operator's phone, but a
|
|
107
|
+
* question's deferral is literally "the operator is not available" — it waits
|
|
108
|
+
* for the window and the working-hours catch-up can release it at the next
|
|
109
|
+
* opening. With no window configured the next opening is now, so a question
|
|
110
|
+
* delivers rather than being silently bound to the daily digest. The
|
|
111
|
+
* `interruptOn` list never applies to a question's deferral.
|
|
98
112
|
*/
|
|
99
113
|
export function interruptDisposition(
|
|
100
114
|
policy: ReportingPolicy | undefined,
|
|
101
115
|
category: InterruptCategory,
|
|
102
116
|
at: number,
|
|
103
117
|
): InterruptDisposition {
|
|
104
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
policy !== undefined &&
|
|
120
|
+
category !== QUESTION_KIND &&
|
|
121
|
+
!policy.interruptOn.includes(category)
|
|
122
|
+
) return "digest";
|
|
105
123
|
return availabilityDisposition(policy?.availability, category, at);
|
|
106
124
|
}
|
|
107
125
|
|
|
@@ -148,6 +166,14 @@ export function formatZonedMinute(at: number, timezone: string): string {
|
|
|
148
166
|
return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
|
|
149
167
|
}
|
|
150
168
|
|
|
169
|
+
/** Compact local timestamp for a one-line future, e.g. "2026-08-24 09:00 UTC":
|
|
170
|
+
* the same instant as {@link formatZonedMinute} without the ISO tail, so a
|
|
171
|
+
* held-notice line read on a phone says plainly when the window opens. */
|
|
172
|
+
export function formatNextWindowOpening(at: number, timezone: string): string {
|
|
173
|
+
const local = localMinute(at, timezone);
|
|
174
|
+
return `${local.date} ${local.clock} ${timezone}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
151
177
|
/** One prompt sentence; the runtime gate, not this prose, owns the decision. */
|
|
152
178
|
export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
|
|
153
179
|
const state = availabilityState(policy, now);
|
package/src/backups.ts
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
|
|
20
20
|
/** A timestamp stem safe to embed in a file name. */
|
|
21
|
-
export function backupTimestamp(): string {
|
|
22
|
-
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
21
|
+
export function backupTimestamp(at: number = Date.now()): string {
|
|
22
|
+
return new Date(at).toISOString().replace(/[:.]/g, "-");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -66,6 +66,7 @@ For each one, pick exactly one of three outcomes:
|
|
|
66
66
|
question that unblocks it. Do not guess: a wrong answer costs a worker's whole
|
|
67
67
|
budget and lands a wrong PR, while an unanswered question costs a delay.
|
|
68
68
|
{{MERGE_DUTY}}
|
|
69
|
+
{{REVIEW_DUTY}}
|
|
69
70
|
|
|
70
71
|
**Then check for orphans.** A worker is a process, and processes die: a daemon
|
|
71
72
|
restart, a host reboot, a kill. The `agent:in-progress` label survives that death
|
package/src/briefs/worker.md
CHANGED
|
@@ -47,7 +47,7 @@ files are canonical; your priors are not.
|
|
|
47
47
|
|
|
48
48
|
{{ACCEPTANCE_CRITERIA}}
|
|
49
49
|
|
|
50
|
-
{{ISSUE_COMMENTS}}## How to work
|
|
50
|
+
{{ISSUE_COMMENTS}}{{FILE_LANE}}## How to work
|
|
51
51
|
|
|
52
52
|
1. **Understand before editing — and ask the graph before you grep.** Your turns
|
|
53
53
|
are mostly spent finding code, not writing it, and running out of turns
|
|
@@ -76,6 +76,18 @@ files are canonical; your priors are not.
|
|
|
76
76
|
genuinely cannot be done small, stop and escalate rather than ballooning.
|
|
77
77
|
5. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
|
|
78
78
|
assertion, or special-case an input to make a check pass.
|
|
79
|
+
6. **Prefer the structured edit tool for file changes, and the structured
|
|
80
|
+
search tool over shelling out.** A structured edit is one call: anchored and
|
|
81
|
+
verified, and a stale anchor fails loudly instead of silently editing the
|
|
82
|
+
wrong line. The shell equivalent is three or four — compose the script,
|
|
83
|
+
escape it correctly, run it, then read the file back to confirm it did what
|
|
84
|
+
was intended — and a mis-escaped `sed -i` pattern silently edits nothing or
|
|
85
|
+
the wrong line. So do not hand-roll edits through `sed -i`, `python3`
|
|
86
|
+
heredocs, `node -e` or shell redirection except where no structured tool can
|
|
87
|
+
express the change (a binary file, a generated artefact). The same holds for
|
|
88
|
+
finding code: use the structured search tool rather than shelling out to
|
|
89
|
+
grep, for the same reason the graph line exists — it is cheaper per call and
|
|
90
|
+
its output is already scoped.
|
|
79
91
|
|
|
80
92
|
## Tests — read this carefully
|
|
81
93
|
|
|
@@ -98,6 +110,8 @@ These are the exact gates for `{{REPO}}`:
|
|
|
98
110
|
|
|
99
111
|
{{GATES}}
|
|
100
112
|
|
|
113
|
+
{{SHARED_HOST_NOTICE}}
|
|
114
|
+
|
|
101
115
|
Run every one of them, from the directory listed, over the **whole tree** — not
|
|
102
116
|
just the directory you edited. Linting only the source dir is how an error in a
|
|
103
117
|
migration, a config file or a script reaches the runners.
|
|
@@ -208,24 +222,29 @@ Escalating is a successful outcome. Guessing is not.
|
|
|
208
222
|
|
|
209
223
|
## Your final report
|
|
210
224
|
|
|
211
|
-
End
|
|
212
|
-
|
|
225
|
+
End your run by yielding the settlement through the `yield` tool — one
|
|
226
|
+
structured call, and the schema is the contract: the harness showed it to you
|
|
227
|
+
at session start. The expected shape:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"status": "green",
|
|
232
|
+
"prUrl": "https://github.com/.../pull/N",
|
|
233
|
+
"headSha": "<the 40-char head you watched go green>",
|
|
234
|
+
"summary": "What you changed and why — the narrative a reviewer reads.",
|
|
235
|
+
"proof": ["bun test omp/src/worker.test.ts"]
|
|
236
|
+
}
|
|
213
237
|
```
|
|
214
|
-
issue: {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
|
|
215
|
-
pr: <url or "none">
|
|
216
|
-
head: <40-character head SHA or "none">
|
|
217
|
-
state: pushed-green | blocked | failed
|
|
218
|
-
gates: <exact commands run and their results>
|
|
219
|
-
changed: <the settlement derives this from the PR diff — omit the line>
|
|
220
|
-
next: <nothing | the specific decision needed>
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
The `changed:` line is not yours to write from memory: the settlement replaces
|
|
224
|
-
it with the actual file list from the PR's diff. Omit it, or write it wrongly —
|
|
225
|
-
the settled report carries the diff's list either way. The narrative in your
|
|
226
|
-
report (what you changed and why, above these lines) is the part only you can
|
|
227
|
-
write, and it is the part a reviewer reads.
|
|
228
238
|
|
|
229
|
-
|
|
230
|
-
`
|
|
239
|
+
Call it as `yield({ result: { data: <the object> } })` with no `type` — the
|
|
240
|
+
usual terminal yield. `status: "green"` means you pushed and **watched the
|
|
241
|
+
checks go green**, and it requires both `prUrl` and `headSha`. Use `blocked`
|
|
242
|
+
(with `blockers`) when a decision or credential is missing, `failed` when the
|
|
243
|
+
run could not complete. The dispatcher renders your yielded settlement into
|
|
244
|
+
the stored report, so `summary` is what a reviewer reads and `proof` is the
|
|
245
|
+
evidence; the `changed:` file list is derived from the PR's own diff, never
|
|
246
|
+
written by you.
|
|
247
|
+
|
|
248
|
+
Never report success you have not observed. "Should pass CI" is not a state,
|
|
249
|
+
and `green` means you watched the checks go green — not that you expect them
|
|
231
250
|
to.
|
package/src/command-help.ts
CHANGED
|
@@ -168,9 +168,16 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
|
|
|
168
168
|
condition the daemon checks for you; a met watch wakes the next tick
|
|
169
169
|
with its note, exactly as a met question does, but it is listed under
|
|
170
170
|
its own heading and never under "Open operator decisions", and it has
|
|
171
|
-
no seven-day expiry. \`watch list\` shows open watches
|
|
171
|
+
no seven-day expiry. \`watch list\` shows open watches; \`watch
|
|
172
|
+
withdraw <id>\` ends one with a recorded reason — the verb that
|
|
173
|
+
creates a watch is the verb that ends it (watches are decision rows,
|
|
174
|
+
so \`decision withdraw <id>\` also works). A watch whose PR condition
|
|
175
|
+
can no longer be observed — the PR merged or closed before the
|
|
176
|
+
condition was seen — is withdrawn by the daemon itself with the
|
|
177
|
+
reason recorded.
|
|
172
178
|
watch add --note TEXT [--blocks TEXT] [--resolves-when COND]
|
|
173
179
|
watch list
|
|
180
|
+
watch withdraw <id> [--reason TEXT]
|
|
174
181
|
intake keep a raw idea durably before it becomes anything: record it now
|
|
175
182
|
with \`omp-conductor intake "<text>"\`, list what is still pending,
|
|
176
183
|
dismiss what turned out to be nothing. Backed by the sqlite store,
|
package/src/command-manifest.ts
CHANGED
|
@@ -390,24 +390,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
390
390
|
},
|
|
391
391
|
{
|
|
392
392
|
name: "watch",
|
|
393
|
-
description: "record or
|
|
393
|
+
description: "record, list, or withdraw orchestrator-only conditions and carry notes",
|
|
394
394
|
scope: "project",
|
|
395
395
|
usage: [
|
|
396
396
|
"watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
|
|
397
397
|
"watch list [--project NAME] [--json]",
|
|
398
|
+
"watch withdraw <id> [--reason TEXT] [--project NAME]",
|
|
398
399
|
],
|
|
399
400
|
subcommands: [
|
|
400
401
|
{ name: "add", description: "record a watch" },
|
|
401
402
|
{ name: "list", description: "list open watches" },
|
|
403
|
+
{ name: "withdraw", description: "withdraw an obsolete watch" },
|
|
402
404
|
],
|
|
403
405
|
flags: [
|
|
404
406
|
value("--note", "note carried when the watch resolves"),
|
|
405
407
|
value("--blocks", "what the watch blocks"),
|
|
406
408
|
value("--resolves-when", "automatic resolution condition"),
|
|
409
|
+
value("--reason", "withdrawal reason"),
|
|
407
410
|
toggle("--json", "print watch list as stable JSON"),
|
|
408
411
|
project(),
|
|
409
412
|
],
|
|
410
|
-
positionals: [{ name: "action" }],
|
|
413
|
+
positionals: [{ name: "action" }, { name: "id" }],
|
|
411
414
|
},
|
|
412
415
|
{
|
|
413
416
|
name: "intake",
|
package/src/commands/arm.ts
CHANGED
|
@@ -12,14 +12,17 @@ import { withProgress } from "../ui/progress.ts";
|
|
|
12
12
|
|
|
13
13
|
export async function armCommand(ctx: CommandContext): Promise<void> {
|
|
14
14
|
for (const project of ctx.targetProjects()) {
|
|
15
|
+
// Proof-neutral wording: `claim-only` performs no Telegram send, so the
|
|
16
|
+
// progress line cannot promise a challenge that never goes out (#613). The
|
|
17
|
+
// result line names the proof that actually armed it.
|
|
15
18
|
const r = await withProgress(
|
|
16
|
-
"arm:
|
|
17
|
-
"
|
|
19
|
+
"arm: verifying the arming proof…",
|
|
20
|
+
"Arming proof verified",
|
|
18
21
|
() => armTicks(project.name),
|
|
19
22
|
{ plainMessage: true },
|
|
20
23
|
);
|
|
21
24
|
process.stdout.write(
|
|
22
|
-
`ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
|
|
25
|
+
`ARMED — ${r.proof === "claim-only" ? "claim-only plumbing verdict proved" : `inbound round-trip proved with owner ${r.owner}`}; ticks are now live.\n` +
|
|
23
26
|
`marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
|
|
24
27
|
);
|
|
25
28
|
}
|
package/src/commands/message.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import type { CommandContext } from "./context.ts";
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { availabilityState, formatNextWindowOpening } from "../availability.ts";
|
|
18
19
|
import { findProject, loadConfig } from "../config.ts";
|
|
19
20
|
import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
|
|
20
21
|
import { dbPath, openStore } from "../store.ts";
|
|
21
|
-
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
|
|
22
|
+
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory, type ProjectConfig } from "../types.ts";
|
|
22
23
|
import { validateQuestionShape } from "../ask.ts";
|
|
23
24
|
|
|
24
25
|
/** The floor's "this needs an answer" marker, as every other classifier reads it. */
|
|
@@ -96,8 +97,35 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
|
|
|
96
97
|
? // Not "into the topic": a stale topic degrades to the flat chat with
|
|
97
98
|
// its own warning on stderr, and this line must not contradict it.
|
|
98
99
|
`message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
|
|
99
|
-
:
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
: heldLine(project, outcome),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The held-notice line names the future that releases it, in words an
|
|
105
|
+
* operator can act on: the working-hours window opening, or the daily digest.
|
|
106
|
+
* The earlier spelling — "the next digest or working-hours catch-up" — read
|
|
107
|
+
* the same for both and hid exactly the 23-hour hold that #596 is about. */
|
|
108
|
+
function heldLine(
|
|
109
|
+
project: ProjectConfig,
|
|
110
|
+
outcome: Extract<OperatorMessageOutcome, { kind: "held" }>,
|
|
111
|
+
): string {
|
|
112
|
+
if (outcome.reason === "availability") {
|
|
113
|
+
const state = availabilityState(project.reporting, Date.now());
|
|
114
|
+
const opening =
|
|
115
|
+
state.mode === "quiet" && state.nextTransitionAt !== undefined && state.timezone !== undefined
|
|
116
|
+
? ` at ${formatNextWindowOpening(state.nextTransitionAt, state.timezone)}`
|
|
117
|
+
: "";
|
|
118
|
+
return (
|
|
119
|
+
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
120
|
+
`held until the working-hours window opens${opening})\n` +
|
|
121
|
+
"nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const digestAt = project.reporting?.digest.at;
|
|
125
|
+
const when = digestAt === undefined ? "the next digest" : `the ${digestAt} digest`;
|
|
126
|
+
return (
|
|
127
|
+
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
128
|
+
`digest-only — held until ${when})\n` +
|
|
129
|
+
`nothing was sent; the daemon releases it with ${when}\n`
|
|
102
130
|
);
|
|
103
131
|
}
|
package/src/commands/watch.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* next tick should read, neither of which ever needs an operator answer. It is
|
|
11
11
|
* distinguished durably by its `kind`, never by whether it carries a
|
|
12
12
|
* condition — a real question may carry one too.
|
|
13
|
+
*
|
|
14
|
+
* Watches are decision rows with `kind === "watch"`, so `watch withdraw`
|
|
15
|
+
* delegates to the same store resolution `decision withdraw` uses rather than
|
|
16
|
+
* inventing a second mechanism: both verbs write the same durable terminal
|
|
17
|
+
* state with a recorded reason, and a watch id remains acceptable to `decision
|
|
18
|
+
* withdraw` for anyone who learned that vocabulary first (#664).
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { CommandContext } from "./context.ts";
|
|
@@ -17,8 +23,25 @@ import { findProject, loadConfig } from "../config.ts";
|
|
|
17
23
|
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
18
24
|
import { dbPath, openStore } from "../store.ts";
|
|
19
25
|
|
|
26
|
+
const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
|
|
27
|
+
|
|
28
|
+
usage:
|
|
29
|
+
omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
30
|
+
omp-conductor watch list [--project NAME] [--json]
|
|
31
|
+
omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
|
|
32
|
+
|
|
33
|
+
add records a row the daemon checks for you and the next tick reads, with no
|
|
34
|
+
operator answer needed. list shows open watches, oldest first. withdraw ends
|
|
35
|
+
one with a recorded reason — the verb that creates a watch is the verb that
|
|
36
|
+
ends it. A watch whose PR condition can no longer be observed (the PR merged
|
|
37
|
+
or closed first) is withdrawn by the daemon itself.`;
|
|
38
|
+
|
|
20
39
|
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
21
40
|
const sub = ctx.argv[1];
|
|
41
|
+
if (sub === "--help" || sub === "-h") {
|
|
42
|
+
process.stdout.write(WATCH_USAGE);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
22
45
|
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
23
46
|
const store = openStore(dbPath());
|
|
24
47
|
try {
|
|
@@ -45,7 +68,9 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
45
68
|
at: Date.now(),
|
|
46
69
|
});
|
|
47
70
|
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
48
|
-
process.stdout.write(
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
`watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
|
|
73
|
+
);
|
|
49
74
|
return;
|
|
50
75
|
}
|
|
51
76
|
|
|
@@ -70,14 +95,48 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
|
70
95
|
for (const watch of watches) {
|
|
71
96
|
process.stdout.write(
|
|
72
97
|
`${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
|
|
73
|
-
`condition:${watch.condition ?? "-"} ${watch.note}\n`,
|
|
98
|
+
`condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id})\n`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (sub === "withdraw") {
|
|
105
|
+
const id = ctx.argv[2];
|
|
106
|
+
if (id === undefined || id.startsWith("--")) {
|
|
107
|
+
process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
const row = store.decision(id);
|
|
111
|
+
if (row !== undefined && row.kind !== "watch") {
|
|
112
|
+
// The same underlying row, but a question answers to a human: the
|
|
113
|
+
// watch verb must not silently close an operator decision.
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`omp-conductor: watch withdraw targets watches — ${id} is an operator decision; use decision withdraw\n`,
|
|
116
|
+
);
|
|
117
|
+
process.exit(2);
|
|
118
|
+
}
|
|
119
|
+
const reason = ctx.flag("reason")?.trim();
|
|
120
|
+
const ok = store.resolveDecision(
|
|
121
|
+
id,
|
|
122
|
+
"withdrawn",
|
|
123
|
+
reason === undefined || reason.length === 0 ? "withdrawn" : reason,
|
|
124
|
+
Date.now(),
|
|
125
|
+
);
|
|
126
|
+
if (!ok) {
|
|
127
|
+
// A watch that is not open is a different mistake from an id that
|
|
128
|
+
// never existed, and the operator can only act on one of them.
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`omp-conductor: no open watch ${id} for ${project.name} — it was already withdrawn, or the id is wrong\n`,
|
|
74
131
|
);
|
|
132
|
+
process.exit(1);
|
|
75
133
|
}
|
|
134
|
+
process.stdout.write(`watch ${id} withdrawn\n`);
|
|
76
135
|
return;
|
|
77
136
|
}
|
|
78
137
|
|
|
79
138
|
process.stderr.write(
|
|
80
|
-
`omp-conductor: unknown watch subcommand "${sub}" — expected add or
|
|
139
|
+
`omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`,
|
|
81
140
|
);
|
|
82
141
|
process.exit(2);
|
|
83
142
|
} finally {
|
package/src/config-schema.ts
CHANGED
|
@@ -22,13 +22,17 @@
|
|
|
22
22
|
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
import {
|
|
25
|
+
ARM_PROOFS,
|
|
25
26
|
AUTHORITY_HOLDERS,
|
|
26
27
|
BASE_FRESHNESS,
|
|
27
28
|
BEHIND_BASE_ACTIONS,
|
|
28
29
|
CONFIG_VERSION,
|
|
30
|
+
DEFAULT_ARM_PROOF,
|
|
29
31
|
DEFAULT_AUTHORITY,
|
|
30
32
|
DEFAULT_CAPS,
|
|
31
33
|
DEFAULT_PROJECT_POLICY,
|
|
34
|
+
DEFAULT_REVIEW_MAX_ROUNDS,
|
|
35
|
+
DEFAULT_REVIEW_STRICTNESS,
|
|
32
36
|
DRAFT_POLICIES,
|
|
33
37
|
INTERRUPT_CATEGORIES,
|
|
34
38
|
LEGACY_RELEASE_POLICIES,
|
|
@@ -37,6 +41,9 @@ import {
|
|
|
37
41
|
RELEASE_REQUIREMENTS,
|
|
38
42
|
RELEASE_SHAPES,
|
|
39
43
|
REPORT_SCOPES,
|
|
44
|
+
REVIEW_MAX_ROUNDS_MAX,
|
|
45
|
+
REVIEW_MAX_ROUNDS_MIN,
|
|
46
|
+
REVIEW_STRICTNESS,
|
|
40
47
|
WEEKDAYS,
|
|
41
48
|
DIGEST_CADENCES,
|
|
42
49
|
} from "./types.ts";
|
|
@@ -63,6 +70,7 @@ const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
|
|
|
63
70
|
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
64
71
|
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
65
72
|
const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
|
|
73
|
+
const REVIEW_STRICTNESS_LIST = quoteList(REVIEW_STRICTNESS);
|
|
66
74
|
|
|
67
75
|
// ---------------------------------------------------------------------------
|
|
68
76
|
// Closed vocabularies — every one built from the exported `as const` array in
|
|
@@ -81,6 +89,7 @@ const releaseRequirementEnum = z.enum([...RELEASE_REQUIREMENTS]);
|
|
|
81
89
|
const releaseShapeEnum = z.enum([...RELEASE_SHAPES]);
|
|
82
90
|
const orchestratorModeEnum = z.enum([...ORCHESTRATOR_MODES]);
|
|
83
91
|
const releasePolicyLegacyEnum = z.enum([...LEGACY_RELEASE_POLICIES]);
|
|
92
|
+
const reviewStrictnessEnum = z.enum([...REVIEW_STRICTNESS]);
|
|
84
93
|
|
|
85
94
|
/** The 24-hour `HH:MM` shape `digest.at` / `availability.start/end` take. */
|
|
86
95
|
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
@@ -239,6 +248,42 @@ const escalationSchema = z
|
|
|
239
248
|
})
|
|
240
249
|
.strict();
|
|
241
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The per-project arming gate (conductor #613): how `arm` proves a human just
|
|
253
|
+
* approved dispatch. Absent or a legacy config without the key loads as
|
|
254
|
+
* `challenge` — the authenticated round-trip, which is what existing installs
|
|
255
|
+
* already run. `claim-only` arms on the shared live-plumbing verdict with no
|
|
256
|
+
* Telegram send or wait, so an unattended recovery can re-arm a project that
|
|
257
|
+
* opted in.
|
|
258
|
+
*/
|
|
259
|
+
const armSchema = z
|
|
260
|
+
.object({
|
|
261
|
+
proof: z.enum([...ARM_PROOFS]).default(DEFAULT_ARM_PROOF),
|
|
262
|
+
})
|
|
263
|
+
.strict()
|
|
264
|
+
.describe("How `arm` proves a human just approved arming");
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The per-project review policy (#678): how strictly green PRs are reviewed
|
|
268
|
+
* and returned, and the hard ceiling on review rounds per PR lifecycle. Absent
|
|
269
|
+
* or a legacy config without the key loads as `medium` with
|
|
270
|
+
* {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds — the recommended default for a new
|
|
271
|
+
* project, materialised deterministically for every existing one, with the
|
|
272
|
+
* schema bounds and the runtime defaults read from the same constants.
|
|
273
|
+
*/
|
|
274
|
+
const reviewSchema = z
|
|
275
|
+
.object({
|
|
276
|
+
strictness: reviewStrictnessEnum.default(DEFAULT_REVIEW_STRICTNESS),
|
|
277
|
+
maxRounds: z
|
|
278
|
+
.number()
|
|
279
|
+
.int()
|
|
280
|
+
.min(REVIEW_MAX_ROUNDS_MIN)
|
|
281
|
+
.max(REVIEW_MAX_ROUNDS_MAX)
|
|
282
|
+
.default(DEFAULT_REVIEW_MAX_ROUNDS),
|
|
283
|
+
})
|
|
284
|
+
.strict()
|
|
285
|
+
.describe("Review strictness and round ceiling for green PRs");
|
|
286
|
+
|
|
242
287
|
const releasePolicySchema = z.union([
|
|
243
288
|
releasePolicyLegacyEnum,
|
|
244
289
|
z.record(z.string(), authorityHolderEnum),
|
|
@@ -287,6 +332,7 @@ const stateLabelsSchema = z
|
|
|
287
332
|
inProgress: z.unknown(),
|
|
288
333
|
blocked: z.unknown(),
|
|
289
334
|
failed: z.unknown(),
|
|
335
|
+
backlog: z.unknown(),
|
|
290
336
|
})
|
|
291
337
|
.partial();
|
|
292
338
|
|
|
@@ -330,6 +376,12 @@ const projectSchema = z
|
|
|
330
376
|
// overlay); anything else is dropped by the loader, like `workerModel`.
|
|
331
377
|
workerAdvisor: z.unknown().optional(),
|
|
332
378
|
escalation: escalationSchema.optional(),
|
|
379
|
+
// How `arm` proves a human approved arming (#613); absent loads as
|
|
380
|
+
// `challenge`, preserving today's authenticated round-trip.
|
|
381
|
+
arm: armSchema.optional(),
|
|
382
|
+
// Review strictness and the round ceiling (#678); absent loads as the
|
|
383
|
+
// documented migration default.
|
|
384
|
+
review: reviewSchema.optional(),
|
|
333
385
|
authority: authoritySchema.optional(),
|
|
334
386
|
releasePolicy: releasePolicySchema.optional(),
|
|
335
387
|
policy: projectPolicySchema.optional(),
|
|
@@ -428,4 +480,5 @@ export {
|
|
|
428
480
|
RELEASE_SHAPE_LIST,
|
|
429
481
|
ORCHESTRATOR_MODE_LIST,
|
|
430
482
|
LEGACY_RELEASE_POLICY_LIST,
|
|
483
|
+
REVIEW_STRICTNESS_LIST,
|
|
431
484
|
};
|