omp-conductor 0.17.1 → 0.18.1

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 (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
@@ -31,6 +31,8 @@ import {
31
31
  DEFAULT_AUTHORITY,
32
32
  DEFAULT_CAPS,
33
33
  DEFAULT_PROJECT_POLICY,
34
+ DEFAULT_REVIEW_MAX_ROUNDS,
35
+ DEFAULT_REVIEW_STRICTNESS,
34
36
  DRAFT_POLICIES,
35
37
  INTERRUPT_CATEGORIES,
36
38
  LEGACY_RELEASE_POLICIES,
@@ -39,6 +41,9 @@ import {
39
41
  RELEASE_REQUIREMENTS,
40
42
  RELEASE_SHAPES,
41
43
  REPORT_SCOPES,
44
+ REVIEW_MAX_ROUNDS_MAX,
45
+ REVIEW_MAX_ROUNDS_MIN,
46
+ REVIEW_STRICTNESS,
42
47
  WEEKDAYS,
43
48
  DIGEST_CADENCES,
44
49
  } from "./types.ts";
@@ -65,6 +70,7 @@ const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
65
70
  const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
66
71
  const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
67
72
  const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
73
+ const REVIEW_STRICTNESS_LIST = quoteList(REVIEW_STRICTNESS);
68
74
 
69
75
  // ---------------------------------------------------------------------------
70
76
  // Closed vocabularies — every one built from the exported `as const` array in
@@ -83,6 +89,7 @@ const releaseRequirementEnum = z.enum([...RELEASE_REQUIREMENTS]);
83
89
  const releaseShapeEnum = z.enum([...RELEASE_SHAPES]);
84
90
  const orchestratorModeEnum = z.enum([...ORCHESTRATOR_MODES]);
85
91
  const releasePolicyLegacyEnum = z.enum([...LEGACY_RELEASE_POLICIES]);
92
+ const reviewStrictnessEnum = z.enum([...REVIEW_STRICTNESS]);
86
93
 
87
94
  /** The 24-hour `HH:MM` shape `digest.at` / `availability.start/end` take. */
88
95
  const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -256,6 +263,27 @@ const armSchema = z
256
263
  .strict()
257
264
  .describe("How `arm` proves a human just approved arming");
258
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
+
259
287
  const releasePolicySchema = z.union([
260
288
  releasePolicyLegacyEnum,
261
289
  z.record(z.string(), authorityHolderEnum),
@@ -304,9 +332,29 @@ const stateLabelsSchema = z
304
332
  inProgress: z.unknown(),
305
333
  blocked: z.unknown(),
306
334
  failed: z.unknown(),
335
+ backlog: z.unknown(),
307
336
  })
308
337
  .partial();
309
338
 
339
+ // ---------------------------------------------------------------------------
340
+ // Host constraints (#721)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ const hostConstraintsSchema = z
344
+ .object({
345
+ description: z.string().min(1).describe("What this host is and what else it runs"),
346
+ path: z
347
+ .string()
348
+ .min(1)
349
+ .describe('The non-interactive PATH a script or `ssh host "<cmd>"` invocation must export'),
350
+ conventions: z
351
+ .record(z.string().min(1), z.string().min(1))
352
+ .describe("Per-repo command conventions, keyed by the brief's `owner/repo` slug"),
353
+ })
354
+ .strict()
355
+ .partial()
356
+ .describe("Operator-authored host facts rendered into every worker brief");
357
+
310
358
  // ---------------------------------------------------------------------------
311
359
  // Project / root
312
360
  // ---------------------------------------------------------------------------
@@ -350,6 +398,9 @@ const projectSchema = z
350
398
  // How `arm` proves a human approved arming (#613); absent loads as
351
399
  // `challenge`, preserving today's authenticated round-trip.
352
400
  arm: armSchema.optional(),
401
+ // Review strictness and the round ceiling (#678); absent loads as the
402
+ // documented migration default.
403
+ review: reviewSchema.optional(),
353
404
  authority: authoritySchema.optional(),
354
405
  releasePolicy: releasePolicySchema.optional(),
355
406
  policy: projectPolicySchema.optional(),
@@ -377,6 +428,11 @@ const configSchema = z
377
428
  .min(1, "must name a directory")
378
429
  .optional()
379
430
  .describe("Absolute directory for restorable conductor.db snapshots; defaults to <stateDir>/backups/db"),
431
+ // Cores and RAM are derived by host.ts at render time; only what the
432
+ // operator must type belongs in this block.
433
+ host: hostConstraintsSchema
434
+ .optional()
435
+ .describe("Host facts every worker brief renders; absent renders no section"),
380
436
  projects: z.array(projectSchema).min(1, `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`),
381
437
  })
382
438
  .loose()
@@ -448,4 +504,5 @@ export {
448
504
  RELEASE_SHAPE_LIST,
449
505
  ORCHESTRATOR_MODE_LIST,
450
506
  LEGACY_RELEASE_POLICY_LIST,
507
+ REVIEW_STRICTNESS_LIST,
451
508
  };
package/src/config.ts CHANGED
@@ -28,6 +28,9 @@ import {
28
28
  DEFAULT_PROJECT_POLICY,
29
29
  DEFAULT_REPORT_POLICY,
30
30
  DEFAULT_REPORT_SCOPE,
31
+ DEFAULT_REVIEW_MAX_ROUNDS,
32
+ DEFAULT_REVIEW_POLICY,
33
+ DEFAULT_REVIEW_STRICTNESS,
31
34
  DENIED_RELEASE_GRANTS,
32
35
  DRAFT_POLICIES,
33
36
  LEGACY_RELEASE_POLICIES,
@@ -37,12 +40,16 @@ import {
37
40
  RELEASE_REQUIREMENTS,
38
41
  RELEASE_SHAPES,
39
42
  REPORT_SCOPES,
43
+ REVIEW_MAX_ROUNDS_MAX,
44
+ REVIEW_MAX_ROUNDS_MIN,
45
+ REVIEW_STRICTNESS,
40
46
  INTERRUPT_CATEGORIES,
41
47
  DIGEST_CADENCES,
42
48
  WEEKDAYS,
43
49
  type Caps,
44
50
  type ConductorConfig,
45
51
  type DigestCadence,
52
+ type HostConstraints,
46
53
  type InterruptCategory,
47
54
  type MergePreconditions,
48
55
  type PlanUsageCap,
@@ -53,6 +60,8 @@ import {
53
60
  type ReleaseRequirement,
54
61
  type ReportScope,
55
62
  type ReportingPolicy,
63
+ type ReviewPolicy,
64
+ type ReviewStrictness,
56
65
  type Weekday,
57
66
  type WeeklyAvailability,
58
67
  type RepoTarget,
@@ -71,6 +80,7 @@ import {
71
80
  ORCHESTRATOR_MODE_LIST,
72
81
  RELEASE_REQUIREMENT_LIST,
73
82
  RELEASE_SHAPE_LIST,
83
+ REVIEW_STRICTNESS_LIST,
74
84
  REPORT_SCOPE_LIST,
75
85
  WEEKDAY_LIST,
76
86
  quoteList,
@@ -98,10 +108,13 @@ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
98
108
  * Used when a project omits `stateLabels`. Namespaced so a human scanning the
99
109
  * tracker can tell dispatcher-written labels from their own.
100
110
  */
101
- const DEFAULT_STATE_LABELS: ProjectConfig["stateLabels"] = {
111
+ export const DEFAULT_STATE_LABELS: ProjectConfig["stateLabels"] = {
102
112
  inProgress: "agent:in-progress",
103
113
  blocked: "agent:blocked",
104
114
  failed: "agent:failed",
115
+ // The operator's park gesture (#507): unnamed by design — it is theirs, and
116
+ // the operator adds it by hand until setup grows a confirm step (slice 2).
117
+ backlog: "backlog",
105
118
  };
106
119
 
107
120
  /**
@@ -330,6 +343,20 @@ export function resolveArmProof(p: ProjectConfig): ArmProof {
330
343
  return p.arm?.proof === "claim-only" ? "claim-only" : DEFAULT_ARM_PROOF;
331
344
  }
332
345
 
346
+ /**
347
+ * The project's review policy (#678), complete.
348
+ *
349
+ * The loader always materialises `review` (finalizeProject), so this is only
350
+ * ever the fallback for a `ProjectConfig` that never went through it — a
351
+ * hand-built one in a test, or a config written before the key existed.
352
+ * Absent resolves to {@link DEFAULT_REVIEW_POLICY}, the documented migration
353
+ * default every existing install deterministically upgrades to. A fresh object
354
+ * each call, so mutating the result cannot reach the config it came from.
355
+ */
356
+ export function resolveReview(p: ProjectConfig): ReviewPolicy {
357
+ return { ...(p.review ?? DEFAULT_REVIEW_POLICY) };
358
+ }
359
+
333
360
  /**
334
361
  * A policy with no array shared with its source.
335
362
  *
@@ -861,6 +888,12 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
861
888
  if (rel.length === 1 && relStr === "policy") {
862
889
  return `policy must be an object with "merge" and "release" sections`;
863
890
  }
891
+ if (rel.length === 1 && relStr === "review") {
892
+ return `review must be an object with "strictness" and "maxRounds"`;
893
+ }
894
+ if (rel.length === 2 && rel[0] === "review" && rel[1] === "maxRounds") {
895
+ return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`;
896
+ }
864
897
  if (rel.length === 2 && relStr === "policy") return `${pathStr} must be an object`;
865
898
  if (rel.length === 1 && relStr === "caps") return `caps must be an object`;
866
899
  if (rel.length === 2 && relStr === "caps" && rel[1] === "planUsage") {
@@ -911,6 +944,7 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
911
944
  if (issue.code === "unrecognized_keys") {
912
945
  const keys = (issue.keys ?? []).join(", ");
913
946
  if (pathStr === "policy") return `policy has unknown key(s): ${keys} — expected "merge" or "release"`;
947
+ if (pathStr === "review") return `review has unknown key(s): ${keys} — expected "strictness" and "maxRounds"`;
914
948
  if (pathStr === "policy.merge") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_MERGE_KEYS}`;
915
949
  if (pathStr === "policy.release") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_RELEASE_KEYS}`;
916
950
  if (pathStr === "caps.planUsage") {
@@ -960,6 +994,9 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
960
994
 
961
995
  // Regex / custom-message failures carry the finding in issue.message.
962
996
  if (issue.code === "too_small") {
997
+ if (pathStr === "review.maxRounds") {
998
+ return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`;
999
+ }
963
1000
  // Empty array (reporting.interruptOn, availability.days) has its own line.
964
1001
  if (parentOf(rel) === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`;
965
1002
  if (pathStr === "reporting.availability.days") return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`;
@@ -975,6 +1012,9 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
975
1012
  }
976
1013
 
977
1014
  if (issue.code === "too_big") {
1015
+ if (pathStr === "review.maxRounds") {
1016
+ return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`;
1017
+ }
978
1018
  if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
979
1019
  if (pathStr === "caps.planUsage.maxUsedFraction") {
980
1020
  return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`;
@@ -1053,8 +1093,48 @@ function finalize(data: unknown, path: string): ConductorConfig {
1053
1093
  });
1054
1094
  }
1055
1095
 
1096
+ const rawHost = root["host"] as Raw | undefined;
1097
+ const host = finalizeHost(rawHost);
1098
+ const rawDbBackupDir = root["dbBackupDir"];
1099
+ const dbBackupDir = typeof rawDbBackupDir === "string" ? rawDbBackupDir : undefined;
1100
+
1056
1101
  if (problems.length > 0) throw new Error(problemEnvelope(path, problems));
1057
- return { version: CONFIG_VERSION, defaults, projects };
1102
+ // `ConductorConfig` is rebuilt here, so every optional top-level key must be
1103
+ // spelled out or it silently vanishes from every loaded config. `dbBackupDir`
1104
+ // and `host` are the two today; a third needs its own line below (#773).
1105
+ return {
1106
+ version: CONFIG_VERSION,
1107
+ defaults,
1108
+ projects,
1109
+ ...(dbBackupDir === undefined ? {} : { dbBackupDir }),
1110
+ ...(host === undefined ? {} : { host }),
1111
+ };
1112
+ }
1113
+
1114
+ /**
1115
+ * The typed host-constraints block (#721), or `undefined` when nothing usable
1116
+ * was configured — matching the schema's "absent renders no section" promise.
1117
+ * Trimming and empty-drop mirror the other hand-edited string fields; an
1118
+ * object whose every key is empty loads as if the field were absent.
1119
+ */
1120
+ function finalizeHost(parsed: Raw | undefined): HostConstraints | undefined {
1121
+ if (parsed === undefined) return undefined;
1122
+ const out: HostConstraints = {};
1123
+ const description = parsed["description"];
1124
+ if (typeof description === "string" && description.trim() !== "") out.description = description.trim();
1125
+ const path = parsed["path"];
1126
+ if (typeof path === "string" && path.trim() !== "") out.path = path.trim();
1127
+ const conventions = parsed["conventions"];
1128
+ if (typeof conventions === "object" && conventions !== null && !Array.isArray(conventions)) {
1129
+ const kept: Record<string, string> = {};
1130
+ for (const [slug, value] of Object.entries(conventions as Record<string, unknown>)) {
1131
+ if (typeof value === "string" && value.trim() !== "" && slug.trim() !== "") {
1132
+ kept[slug.trim()] = value.trim();
1133
+ }
1134
+ }
1135
+ if (Object.keys(kept).length > 0) out.conventions = kept;
1136
+ }
1137
+ return Object.keys(out).length === 0 ? undefined : out;
1058
1138
  }
1059
1139
 
1060
1140
  function finalizeProject(
@@ -1101,6 +1181,7 @@ function finalizeProject(
1101
1181
  );
1102
1182
  }
1103
1183
  const policy = finalizePolicy(p["policy"], label, problems);
1184
+ const review = finalizeReview(p["review"] as Raw | undefined);
1104
1185
  const caps = reconcileCaps(p["caps"], `${label}: caps`, problems, legacyCaps);
1105
1186
  const reporting = finalizeReporting(p["reporting"], label, problems);
1106
1187
  const recoveryMerges = (p["recoveryMerges"] as RecoveryMergeAuthorization[] | undefined)?.map(
@@ -1199,6 +1280,7 @@ function finalizeProject(
1199
1280
  inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress),
1200
1281
  blocked: pickString(stateLabels?.["blocked"], DEFAULT_STATE_LABELS.blocked),
1201
1282
  failed: pickString(stateLabels?.["failed"], DEFAULT_STATE_LABELS.failed),
1283
+ backlog: pickString(stateLabels?.["backlog"], DEFAULT_STATE_LABELS.backlog),
1202
1284
  },
1203
1285
  routing: { labelPrefix, repos },
1204
1286
  caps,
@@ -1208,6 +1290,7 @@ function finalizeProject(
1208
1290
  ...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
1209
1291
  escalation,
1210
1292
  arm,
1293
+ review,
1211
1294
  authority,
1212
1295
  releasePolicy,
1213
1296
  policy,
@@ -1231,6 +1314,23 @@ function finalizeArm(parsed: Raw | undefined): ProjectConfig["arm"] {
1231
1314
  return { proof: (parsed["proof"] as ArmProof | undefined) ?? DEFAULT_ARM_PROOF };
1232
1315
  }
1233
1316
 
1317
+ /**
1318
+ * The project's review policy (#678), complete. A config written before the
1319
+ * key existed — or one that never answered — materialises as
1320
+ * {@link DEFAULT_REVIEW_POLICY}, the documented migration default, so an
1321
+ * upgrade changes no existing project's behaviour beyond what its operators
1322
+ * then choose in setup. zod has already rejected anything outside the
1323
+ * strictness vocabulary or the validated round range, so this only fills the
1324
+ * absent-case default.
1325
+ */
1326
+ function finalizeReview(parsed: Raw | undefined): ProjectConfig["review"] {
1327
+ if (parsed === undefined) return { ...DEFAULT_REVIEW_POLICY };
1328
+ return {
1329
+ strictness: (parsed["strictness"] as ReviewStrictness | undefined) ?? DEFAULT_REVIEW_STRICTNESS,
1330
+ maxRounds: (parsed["maxRounds"] as number | undefined) ?? DEFAULT_REVIEW_MAX_ROUNDS,
1331
+ };
1332
+ }
1333
+
1234
1334
  function finalizeEscalation(parsed: Raw | undefined): ProjectConfig["escalation"] {
1235
1335
  if (parsed === undefined) {
1236
1336
  return { fallbackToIssueComment: true, orchestrator: "embedded" };