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.
Files changed (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -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
- if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return "git-tag";
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 releaseShapeFromTokens(tokens: readonly string[]): ReleaseShape | undefined {
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
- /** Release-shaped device/tool invocations that do not pass through a shell. */
217
- export function releaseShapeFromTool(
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" ? releaseShapeFromCommand(input.command) : undefined;
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 = releaseShapeFromTokens(releaseTokens(path));
270
+ const viaDevice = releaseTokenMatch(releaseTokens(path));
232
271
  if (viaDevice !== undefined) return viaDevice;
233
272
  }
234
273
 
235
- return releaseShapeFromTokens(releaseTokens(toolName));
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 shape = releaseShapeFromTool(toolName, input);
277
- if (shape === undefined) return undefined;
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 ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
230
- ` held escalations ${backlog.heldNoticeCount}${age(backlog.heldNoticeOldestAt)}`,
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: { store: Store; at: number; noticeId: string; send?: ReportSend },
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({