omp-conductor 0.15.9 → 0.15.11
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 +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/src/release-policy.ts
CHANGED
|
@@ -39,6 +39,19 @@ export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
|
|
|
39
39
|
export interface ReleaseBlockContext {
|
|
40
40
|
tool: string;
|
|
41
41
|
reason: string;
|
|
42
|
+
/**
|
|
43
|
+
* What the shape matcher fired on: the offending command segment for a shell
|
|
44
|
+
* call, the deciding token for a tool name. Present on every record written
|
|
45
|
+
* from #491 on, so a fixture hit is visible as one; absent only on older
|
|
46
|
+
* lines, which the digest still renders.
|
|
47
|
+
*/
|
|
48
|
+
matched?: string;
|
|
49
|
+
/**
|
|
50
|
+
* The blocked shell command itself, with anything credential-shaped redacted
|
|
51
|
+
* and the whole string bounded. Only a `bash` call sets it — every other
|
|
52
|
+
* tool's redacted {@link args} already name the invocation.
|
|
53
|
+
*/
|
|
54
|
+
invocation?: string;
|
|
42
55
|
args: Record<string, string>;
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -72,26 +85,41 @@ function stripCommandPrefix(segment: string): string {
|
|
|
72
85
|
|
|
73
86
|
/** Recognise the explicit release/deploy command shapes this policy promises to gate. */
|
|
74
87
|
export function releaseShapeFromCommand(command: string): ReleaseShape | undefined {
|
|
88
|
+
return releaseCommandMatch(command)?.shape;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Classify a shell command and the segment that fired. The segment is what a
|
|
93
|
+
* triager reads: `bun test src/foo.test.ts && npm publish` matches on its
|
|
94
|
+
* `npm publish` half, and the record says so instead of folding the whole
|
|
95
|
+
* command into the shape.
|
|
96
|
+
*/
|
|
97
|
+
function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: string } | undefined {
|
|
75
98
|
for (const raw of commandSegments(command)) {
|
|
76
99
|
const segment = stripCommandPrefix(raw);
|
|
77
|
-
|
|
100
|
+
// Patching the running conductor is gated like a release act: `upgrade`,
|
|
101
|
+
// the detached `upgrade-install`/`upgrade-rollback` executor entries and
|
|
102
|
+
// any future spelling under the upgrade family all need the install shape,
|
|
103
|
+
// and a session without that grant is refused before the shell runs.
|
|
104
|
+
if (/^omp-conductor\s+upgrade(?:\b|$)/.test(segment)) return { shape: "install", matched: segment };
|
|
105
|
+
if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return { shape: "git-tag", matched: segment };
|
|
78
106
|
if (
|
|
79
107
|
/^git(?:\s+-[Cc]\s+\S+)*\s+push\b/.test(segment) &&
|
|
80
108
|
GIT_PUSH_TAG_SHAPE.test(segment)
|
|
81
109
|
) {
|
|
82
|
-
return "git-push-tags";
|
|
110
|
+
return { shape: "git-push-tags", matched: segment };
|
|
83
111
|
}
|
|
84
112
|
if (/^(?:npm|pnpm|bun)\s+(?:publish|stage\s+publish)\b/.test(segment)) {
|
|
85
|
-
return "package-publish";
|
|
113
|
+
return { shape: "package-publish", matched: segment };
|
|
86
114
|
}
|
|
87
|
-
if (/^yarn\s+npm\s+publish\b/.test(segment)) return "package-publish";
|
|
88
|
-
if (/^gh\s+release\s+create\b/.test(segment)) return "github-release";
|
|
115
|
+
if (/^yarn\s+npm\s+publish\b/.test(segment)) return { shape: "package-publish", matched: segment };
|
|
116
|
+
if (/^gh\s+release\s+create\b/.test(segment)) return { shape: "github-release", matched: segment };
|
|
89
117
|
if (
|
|
90
118
|
/^(?:kubectl\s+(?:apply|create|replace)|helm\s+(?:install|upgrade)|terraform\s+apply|pulumi\s+up|(?:fly|vercel|wrangler)\s+deploy|docker\s+push)\b/.test(
|
|
91
119
|
segment,
|
|
92
120
|
)
|
|
93
121
|
) {
|
|
94
|
-
return "deploy";
|
|
122
|
+
return { shape: "deploy", matched: segment };
|
|
95
123
|
}
|
|
96
124
|
}
|
|
97
125
|
return undefined;
|
|
@@ -187,8 +215,11 @@ const RELEASE_ACTIONS: Record<string, true> = {
|
|
|
187
215
|
* only asks a question — while a read verb *after* the action verb is not:
|
|
188
216
|
* `DeployStackAndWatch` deploys.
|
|
189
217
|
*/
|
|
190
|
-
function
|
|
218
|
+
function releaseTokenMatch(
|
|
219
|
+
tokens: readonly string[],
|
|
220
|
+
): { shape: ReleaseShape; matched: string } | undefined {
|
|
191
221
|
if (tokens[tokens.length - 1] === "status") return undefined;
|
|
222
|
+
let action: string | undefined;
|
|
192
223
|
for (const token of tokens) {
|
|
193
224
|
if (READ_VERBS[token] === true) return undefined;
|
|
194
225
|
if (
|
|
@@ -196,31 +227,39 @@ function releaseShapeFromTokens(tokens: readonly string[]): ReleaseShape | undef
|
|
|
196
227
|
RELEASE_ACTIONS[token] === true ||
|
|
197
228
|
PUBLISH_ACTIONS[token] === true
|
|
198
229
|
) {
|
|
230
|
+
action = token;
|
|
199
231
|
break;
|
|
200
232
|
}
|
|
201
233
|
}
|
|
234
|
+
if (action === undefined) return undefined;
|
|
202
235
|
|
|
203
236
|
// `release` first: `PublishRelease` cuts a GitHub release, it does not publish
|
|
204
|
-
// a package, and both vocabularies claim `publish`.
|
|
237
|
+
// a package, and both vocabularies claim `publish`. `matched` names the token
|
|
238
|
+
// the shape was read off, so a name that only grazes a vocabulary shows it.
|
|
205
239
|
if (
|
|
206
240
|
tokens.some((t) => t === "release" || t === "releases") &&
|
|
207
241
|
tokens.some((t) => RELEASE_ACTIONS[t] === true)
|
|
208
242
|
) {
|
|
209
|
-
return "github-release";
|
|
243
|
+
return { shape: "github-release", matched: "release" };
|
|
210
244
|
}
|
|
211
|
-
if (tokens.some((t) => PUBLISH_ACTIONS[t] === true)) return "package-publish";
|
|
212
|
-
if (tokens.some((t) => DEPLOY_ACTIONS[t] === true)) return "deploy";
|
|
245
|
+
if (tokens.some((t) => PUBLISH_ACTIONS[t] === true)) return { shape: "package-publish", matched: action };
|
|
246
|
+
if (tokens.some((t) => DEPLOY_ACTIONS[t] === true)) return { shape: "deploy", matched: action };
|
|
213
247
|
return undefined;
|
|
214
248
|
}
|
|
215
249
|
|
|
216
|
-
/**
|
|
217
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Classify a release-shaped tool call and what matched, like
|
|
252
|
+
* {@link releaseShapeFromTool} plus the substring the shape was read off. One
|
|
253
|
+
* classifier sits behind decisions, audit records and tick prompts, so they
|
|
254
|
+
* cannot disagree about which call was blocked.
|
|
255
|
+
*/
|
|
256
|
+
export function releaseToolMatch(
|
|
218
257
|
toolName: string,
|
|
219
258
|
input: Record<string, unknown> | undefined,
|
|
220
|
-
): ReleaseShape | undefined {
|
|
259
|
+
): { shape: ReleaseShape; matched: string } | undefined {
|
|
221
260
|
if (input === undefined) return undefined;
|
|
222
261
|
if (toolName === "bash") {
|
|
223
|
-
return typeof input.command === "string" ?
|
|
262
|
+
return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined;
|
|
224
263
|
}
|
|
225
264
|
|
|
226
265
|
// An MCP tool reached through the device path: the path *is* the tool name, so
|
|
@@ -228,11 +267,19 @@ export function releaseShapeFromTool(
|
|
|
228
267
|
// `write` to `src/deploy.ts` is source code, not a deploy.
|
|
229
268
|
const path = typeof input.path === "string" ? input.path : "";
|
|
230
269
|
if (toolName === "write" && path.startsWith("xd://")) {
|
|
231
|
-
const viaDevice =
|
|
270
|
+
const viaDevice = releaseTokenMatch(releaseTokens(path));
|
|
232
271
|
if (viaDevice !== undefined) return viaDevice;
|
|
233
272
|
}
|
|
234
273
|
|
|
235
|
-
return
|
|
274
|
+
return releaseTokenMatch(releaseTokens(toolName));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Release-shaped device/tool invocations that do not pass through a shell. */
|
|
278
|
+
export function releaseShapeFromTool(
|
|
279
|
+
toolName: string,
|
|
280
|
+
input: Record<string, unknown> | undefined,
|
|
281
|
+
): ReleaseShape | undefined {
|
|
282
|
+
return releaseToolMatch(toolName, input)?.shape;
|
|
236
283
|
}
|
|
237
284
|
|
|
238
285
|
/**
|
|
@@ -272,11 +319,11 @@ export function releaseDecision(
|
|
|
272
319
|
role: SessionRole,
|
|
273
320
|
toolName: string,
|
|
274
321
|
input: Record<string, unknown>,
|
|
275
|
-
): { shape: ReleaseShape; decision: ReleaseDecision } | undefined {
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
const decision = releaseRefusal(grants, role, shape);
|
|
279
|
-
return decision === undefined ? undefined : { shape, decision };
|
|
322
|
+
): { shape: ReleaseShape; matched: string; decision: ReleaseDecision } | undefined {
|
|
323
|
+
const match = releaseToolMatch(toolName, input);
|
|
324
|
+
if (match === undefined) return undefined;
|
|
325
|
+
const decision = releaseRefusal(grants, role, match.shape);
|
|
326
|
+
return decision === undefined ? undefined : { shape: match.shape, matched: match.matched, decision };
|
|
280
327
|
}
|
|
281
328
|
|
|
282
329
|
const RELEASE_ARG_ALLOWLIST = new Set([
|
|
@@ -305,6 +352,64 @@ export function redactReleaseArgs(input: Record<string, unknown>): Record<string
|
|
|
305
352
|
return redacted;
|
|
306
353
|
}
|
|
307
354
|
|
|
355
|
+
/** Credential-shaped text a blocked invocation must not carry into the audit
|
|
356
|
+
* file, each with the replacement that takes its place. */
|
|
357
|
+
const CREDENTIAL_PATTERNS: Array<readonly [RegExp, string]> = [
|
|
358
|
+
// Armoured private keys, in full so a key cannot survive as its own body.
|
|
359
|
+
[/-----BEGIN [A-Z0-9 ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]+PRIVATE KEY-----/g, "[redacted]"],
|
|
360
|
+
// GitHub classic and fine-grained PATs.
|
|
361
|
+
[/gh[pousr]_[A-Za-z0-9]{20,}/g, "[redacted]"],
|
|
362
|
+
[/github_pat_[A-Za-z0-9_]{20,}/g, "[redacted]"],
|
|
363
|
+
// Slack tokens.
|
|
364
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/g, "[redacted]"],
|
|
365
|
+
// AWS access key ids. The secret half is caught by the long-run rule below.
|
|
366
|
+
[/AKIA[0-9A-Z]{16}/g, "[redacted]"],
|
|
367
|
+
// OpenAI-style `sk-` secrets.
|
|
368
|
+
[/sk-[A-Za-z0-9]{16,}/g, "[redacted]"],
|
|
369
|
+
// `--token x`, `--password=x` and friends. The token word must end at the
|
|
370
|
+
// separator, so `--author` stays a git flag rather than dying as a
|
|
371
|
+
// credential. The flag name survives so the record says what kind of
|
|
372
|
+
// credential rode on the call; only its value is replaced.
|
|
373
|
+
[
|
|
374
|
+
/--([a-z0-9-]*(?:token|secret|password|passwd|api-?key|auth(?:orization)?))(?=[\s=])(?:\s+|=)\S+/gi,
|
|
375
|
+
"--$1 [redacted]",
|
|
376
|
+
],
|
|
377
|
+
// `TOKEN=…`, `NPM_CONFIG_TOKEN=…` style assignments.
|
|
378
|
+
[/[A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|PASSWD|SECRET|API_?KEY|CREDENTIAL)[A-Z0-9_]*=\S+/g, "[redacted]"],
|
|
379
|
+
// `Bearer …` / `Basic …` header values.
|
|
380
|
+
[/\b(?:Bearer|Basic)\s+\S+/g, "[redacted]"],
|
|
381
|
+
// Long unbroken runs are token-shaped whatever scheme they belong to. A
|
|
382
|
+
// commit sha is a stranger in a blocked command worth keeping; a leaked
|
|
383
|
+
// token is not worth the triage convenience.
|
|
384
|
+
[/[A-Za-z0-9_-]{32,}/g, "[redacted]"],
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
/** The bound a recorded invocation is cut to. Long enough to read a heredoc,
|
|
388
|
+
* short enough that a fixture dump cannot bloat the audit file. */
|
|
389
|
+
const RELEASE_INVOCATION_MAX = 400;
|
|
390
|
+
|
|
391
|
+
/** Redact anything credential-shaped in a blocked command, then bound its
|
|
392
|
+
* length. Redaction runs before truncation so a cut cannot leak a token
|
|
393
|
+
* fragment past its own pattern. */
|
|
394
|
+
export function redactReleaseCommand(command: string): string {
|
|
395
|
+
let safe = command;
|
|
396
|
+
for (const [pattern, replacement] of CREDENTIAL_PATTERNS) {
|
|
397
|
+
safe = safe.replace(pattern, replacement);
|
|
398
|
+
}
|
|
399
|
+
return safe.length <= RELEASE_INVOCATION_MAX ? safe : `${safe.slice(0, RELEASE_INVOCATION_MAX)}…`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The invocation a block records: the shell command for a `bash` call, and
|
|
403
|
+
* nothing for other tools — their redacted args already name the call. */
|
|
404
|
+
export function releaseBlockedInvocation(
|
|
405
|
+
toolName: string,
|
|
406
|
+
input: Record<string, unknown>,
|
|
407
|
+
): string | undefined {
|
|
408
|
+
return toolName === "bash" && typeof input.command === "string"
|
|
409
|
+
? redactReleaseCommand(input.command)
|
|
410
|
+
: undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
308
413
|
interface ReleasePolicyPi {
|
|
309
414
|
on(
|
|
310
415
|
event: "tool_call",
|
|
@@ -326,9 +431,12 @@ export function releasePolicyTripwire(
|
|
|
326
431
|
const blocked = releaseDecision(grants, role, event.toolName, event.input);
|
|
327
432
|
if (blocked === undefined) return undefined;
|
|
328
433
|
try {
|
|
434
|
+
const invocation = releaseBlockedInvocation(event.toolName, event.input);
|
|
329
435
|
onBlocked(blocked.shape, {
|
|
330
436
|
tool: event.toolName,
|
|
331
437
|
reason: blocked.decision.reason,
|
|
438
|
+
matched: blocked.matched,
|
|
439
|
+
...(invocation === undefined ? {} : { invocation }),
|
|
332
440
|
args: redactReleaseArgs(event.input),
|
|
333
441
|
});
|
|
334
442
|
} catch {
|
|
@@ -350,13 +458,15 @@ export function recordReleaseBlock(
|
|
|
350
458
|
mkdirSync(root, { recursive: true });
|
|
351
459
|
// Redact again at the durable boundary. Callers normally pass the tripwire's
|
|
352
460
|
// already-safe context, but no alternate recorder path gets to rely on that.
|
|
353
|
-
const { args, ...attribution } = details;
|
|
461
|
+
const { args, invocation, matched, ...attribution } = details;
|
|
354
462
|
const record: ReleaseBlock = {
|
|
355
463
|
project,
|
|
356
464
|
source,
|
|
357
465
|
shape,
|
|
358
466
|
...attribution,
|
|
359
467
|
...(args === undefined ? {} : { args: redactReleaseArgs(args) }),
|
|
468
|
+
...(invocation === undefined ? {} : { invocation: redactReleaseCommand(invocation) }),
|
|
469
|
+
...(matched === undefined ? {} : { matched: redactReleaseCommand(matched) }),
|
|
360
470
|
at: now.toISOString(),
|
|
361
471
|
};
|
|
362
472
|
appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
@@ -416,6 +526,8 @@ export function releaseDriftDigestLine(
|
|
|
416
526
|
...(latest.issue === undefined ? [] : [`issue #${latest.issue}`]),
|
|
417
527
|
...(latest.runId === undefined ? [] : [`run ${latest.runId}`]),
|
|
418
528
|
...(latest.tool === undefined ? [] : [`tool ${latest.tool}`]),
|
|
529
|
+
...(latest.invocation === undefined ? [] : [`attempted "${latest.invocation}"`]),
|
|
530
|
+
...(latest.matched === undefined ? [] : [`matched "${latest.matched}"`]),
|
|
419
531
|
].join(", ");
|
|
420
532
|
return (
|
|
421
533
|
`Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
|
package/src/reports.ts
CHANGED
|
@@ -219,15 +219,22 @@ export function formatOpenReports(
|
|
|
219
219
|
|
|
220
220
|
/** Durable rows still owed to a future digest. Always rendered in `status`: a
|
|
221
221
|
* quiet line proves the accumulator is empty, while a non-zero line makes loss
|
|
222
|
-
* or backlog visible without asking the session what it remembers.
|
|
222
|
+
* or backlog visible without asking the session what it remembers. Held
|
|
223
|
+
* notices are split by what releases them: `releaseOnAvailable` rows are
|
|
224
|
+
* interrupt-worthy notices waiting only for the operator's working window,
|
|
225
|
+
* everything else is ordinary digest-bound backlog. One noun for both was how
|
|
226
|
+
* a routine material backlog read as stranded escalations (#492). */
|
|
223
227
|
export function formatDigestBacklog(backlog: DigestBacklog, now: number = Date.now()): string[] {
|
|
224
228
|
const age = (at: number | undefined): string =>
|
|
225
229
|
at === undefined ? "" : ` (oldest ${humanAge(Math.max(0, now - at))})`;
|
|
230
|
+
const heldForAvailability = backlog.availabilityHeldNoticeCount ?? 0;
|
|
231
|
+
const heldForDigest = Math.max(0, backlog.heldNoticeCount - heldForAvailability);
|
|
226
232
|
return [
|
|
227
233
|
"",
|
|
228
234
|
"digest backlog",
|
|
229
|
-
` material events
|
|
230
|
-
` held
|
|
235
|
+
` material events ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
|
|
236
|
+
` held for digest ${heldForDigest}${age(backlog.digestHeldNoticeOldestAt ?? backlog.heldNoticeOldestAt)}`,
|
|
237
|
+
` held for availability ${heldForAvailability}${age(backlog.availabilityHeldNoticeOldestAt)}`,
|
|
231
238
|
];
|
|
232
239
|
}
|
|
233
240
|
|
|
@@ -315,9 +322,16 @@ export function operatorMessageCategory(text: string): InterruptCategory {
|
|
|
315
322
|
export async function deliverOperatorMessage(
|
|
316
323
|
project: ProjectConfig,
|
|
317
324
|
text: string,
|
|
318
|
-
deps: {
|
|
325
|
+
deps: {
|
|
326
|
+
store: Store;
|
|
327
|
+
at: number;
|
|
328
|
+
noticeId: string;
|
|
329
|
+
send?: ReportSend;
|
|
330
|
+
/** Declared escalation category; overrides the `QUESTION:`-prefix read. */
|
|
331
|
+
category?: InterruptCategory;
|
|
332
|
+
},
|
|
319
333
|
): Promise<OperatorMessageOutcome> {
|
|
320
|
-
const category = operatorMessageCategory(text);
|
|
334
|
+
const category = deps.category ?? operatorMessageCategory(text);
|
|
321
335
|
const disposition = interruptDisposition(project.reporting, category, deps.at);
|
|
322
336
|
if (disposition !== "interrupt") {
|
|
323
337
|
deps.store.addHeldNotice({
|