@pleri/olam-cli 0.1.65 → 0.1.66

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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "auth": "sha256:1e6d3574077ba70ba908b5ae343e9ff669937468ff1f48e3e9a449ac38578cc6",
3
- "devbox": "sha256:74ccac7911aad8f9679d27b1c05a74a5414e4aba0c12e336735161ea3d08a19a",
4
- "host-cp": "sha256:c747251056dda0aa8c53a69f69972ff789a842d4ca2cf2cdfd91bc51e803ec9b",
3
+ "devbox": "sha256:51d091385dfea24529a97f0949780b92a3f2fad4011a3b32106645046de3213e",
4
+ "host-cp": "sha256:b96191c00ca6f1ef5c12990a5802f8737a44500c0d28bcea5182052e28cf0838",
5
5
  "mcp-auth": "sha256:cccf9fc022a3b58080007761ff10e4820080d26491e7b6e758e3e766c9eac896",
6
6
  "$schema_version": 1,
7
- "$published_version": "0.1.65",
7
+ "$published_version": "0.1.66",
8
8
  "$registry": "ghcr.io/pleri"
9
9
  }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * PR Nanny — host-side daemon that watches all worlds' open PRs and
3
+ * dispatches fixes via `olam dispatch` when CI/reviews block them.
4
+ *
5
+ * Extends the pr-merge-poller loop pattern. Runs at 60s cadence.
6
+ * Opt-out: OLAM_PR_NANNY=0 (default: enabled).
7
+ *
8
+ * State machine per PR (stored in world-pr-state.json nanny_* fields):
9
+ * watching → dispatching → (paused | escalated | halted)
10
+ *
11
+ * Halt conditions (stop dispatching but keep watching):
12
+ * 1. dispatch_count >= MAX_DISPATCHES (configurable, default 5)
13
+ * 2. wall-clock since first dispatch >= MAX_WALL_CLOCK_MIN (default 60)
14
+ * 3. same-root-cause loop detected (last 2 dispatch summaries identical)
15
+ * 4. operator manual pause
16
+ */
17
+
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ const GH_API_BASE = 'https://api.github.com';
24
+
25
+ // Known external-blocker CI check name patterns.
26
+ // When ALL failing checks match these patterns, the PR is not actionable
27
+ // (the root cause is infrastructure/release-pipeline, not the world's code).
28
+ const EXTERNAL_BLOCKER_PATTERNS = [
29
+ /detect-image-scopes/i,
30
+ /publish-mcp-auth/i,
31
+ /retag-mcp-auth/i,
32
+ /bootstrap.*publish/i,
33
+ /release.*pipeline/i,
34
+ /ghcr.*push/i,
35
+ ];
36
+
37
+ /**
38
+ * @param {string} checkName
39
+ * @returns {boolean}
40
+ */
41
+ function isExternalBlockerCheck(checkName) {
42
+ return EXTERNAL_BLOCKER_PATTERNS.some((re) => re.test(checkName));
43
+ }
44
+
45
+ /**
46
+ * Returns true when ALL failing CI checks are external-blocker patterns.
47
+ * @param {Array<{name: string, conclusion: string|null}>} checks
48
+ */
49
+ export function isExternalBlocker(checks) {
50
+ const failing = checks.filter(
51
+ (c) => c.conclusion === 'failure' || c.conclusion === 'action_required',
52
+ );
53
+ if (failing.length === 0) return false;
54
+ return failing.every((c) => isExternalBlockerCheck(c.name));
55
+ }
56
+
57
+ /**
58
+ * @param {string} prUrl e.g. https://github.com/org/repo/pull/123
59
+ * @returns {{ owner: string, repo: string, number: number } | null}
60
+ */
61
+ function parsePrUrl(prUrl) {
62
+ const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prUrl);
63
+ if (!m) return null;
64
+ return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
65
+ }
66
+
67
+ /**
68
+ * @param {{
69
+ * prStateStore: ReturnType<import('./world-pr-state.mjs').createWorldPrStateStore>,
70
+ * getGhToken: () => Promise<string|null>,
71
+ * dispatchToWorld: (worldId: string, prompt: string) => Promise<void>,
72
+ * consultCodex: (ctx: string) => Promise<string>,
73
+ * pollIntervalMs?: number,
74
+ * maxDispatches?: number,
75
+ * maxWallClockMin?: number,
76
+ * }} opts
77
+ */
78
+ export function createPrNanny({
79
+ prStateStore,
80
+ getGhToken,
81
+ dispatchToWorld,
82
+ consultCodex,
83
+ pollIntervalMs = 60_000,
84
+ maxDispatches = parseInt(process.env.OLAM_PR_NANNY_MAX_DISPATCHES ?? '5', 10),
85
+ maxWallClockMin = parseInt(process.env.OLAM_PR_NANNY_MAX_WALL_CLOCK_MIN ?? '60', 10),
86
+ }) {
87
+ const enabled = (process.env.OLAM_PR_NANNY ?? '1') !== '0';
88
+ if (!enabled) return { start() {}, stop() {} };
89
+
90
+ let intervalId = null;
91
+ let warnedOnce = false;
92
+
93
+ /**
94
+ * Fetch CI check runs for the PR's head SHA.
95
+ * @param {string} owner @param {string} repo @param {number} prNumber @param {string} ghToken
96
+ * @returns {Promise<Array<{name: string, conclusion: string|null}>>}
97
+ */
98
+ async function fetchChecks(owner, repo, prNumber, ghToken) {
99
+ try {
100
+ // First get the PR head SHA
101
+ const prRes = await fetch(
102
+ `${GH_API_BASE}/repos/${owner}/${repo}/pulls/${prNumber}`,
103
+ { headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' } },
104
+ );
105
+ if (!prRes.ok) return [];
106
+ const prData = await prRes.json();
107
+ const sha = prData.head?.sha;
108
+ if (!sha) return [];
109
+
110
+ const checkRes = await fetch(
111
+ `${GH_API_BASE}/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`,
112
+ { headers: { Authorization: `token ${ghToken}`, Accept: 'application/vnd.github+json' } },
113
+ );
114
+ if (!checkRes.ok) return [];
115
+ const checkData = await checkRes.json();
116
+ return (checkData.check_runs ?? []).map((r) => ({
117
+ name: r.name,
118
+ conclusion: r.conclusion,
119
+ status: r.status,
120
+ }));
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ /**
127
+ * @param {string} worldId
128
+ * @param {object} entry current pr-state entry
129
+ * @param {string} ghToken
130
+ */
131
+ async function processWorld(worldId, entry, ghToken) {
132
+ if (entry.nanny_paused || entry.nanny_escalated) return;
133
+ if (entry.pr_state !== 'open') return;
134
+
135
+ const parsed = parsePrUrl(entry.pr_url);
136
+ if (!parsed) return;
137
+
138
+ // Halt: dispatch cap
139
+ const dispatchCount = entry.nanny_dispatch_count ?? 0;
140
+ if (dispatchCount >= maxDispatches) return;
141
+
142
+ // Halt: wall-clock ceiling
143
+ if (entry.nanny_first_dispatch_at) {
144
+ const elapsedMin = (Date.now() - new Date(entry.nanny_first_dispatch_at).getTime()) / 60_000;
145
+ if (elapsedMin >= maxWallClockMin) return;
146
+ }
147
+
148
+ const checks = await fetchChecks(parsed.owner, parsed.repo, parsed.number, ghToken);
149
+ const hasCiFailure = checks.some(
150
+ (c) => c.conclusion === 'failure' || c.conclusion === 'action_required',
151
+ );
152
+ const allPassing = checks.length > 0 && checks.every(
153
+ (c) => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral',
154
+ );
155
+
156
+ if (allPassing || checks.length === 0) return;
157
+ if (!hasCiFailure) return;
158
+
159
+ // External blocker — do not dispatch
160
+ if (isExternalBlocker(checks)) {
161
+ prStateStore.set(worldId, { nanny_external_blocker: true });
162
+ return;
163
+ }
164
+
165
+ prStateStore.set(worldId, { nanny_external_blocker: false });
166
+
167
+ const failingNames = checks
168
+ .filter((c) => c.conclusion === 'failure' || c.conclusion === 'action_required')
169
+ .map((c) => c.name)
170
+ .join(', ');
171
+
172
+ const prompt = `CI is failing on PR ${entry.pr_url}. Failing checks: ${failingNames}. Investigate the root cause, fix the code, and push.`;
173
+
174
+ // Halt: same-root-cause loop detection
175
+ if (entry.nanny_last_dispatch_prompt && entry.nanny_last_dispatch_prompt === prompt) {
176
+ console.log(`[pr-nanny] loop detected for ${worldId} — same prompt as last dispatch, halting`);
177
+ prStateStore.set(worldId, { nanny_loop_halted: true });
178
+ return;
179
+ }
180
+
181
+ // Consult Codex before dispatching
182
+ const codexCtx = `World ${worldId} has a failing PR: ${entry.pr_url}. Failing CI checks: ${failingNames}. Should we dispatch a fix? Answer: agree, push-back, or rethink.`;
183
+ let verdict = 'agree';
184
+ try {
185
+ verdict = await consultCodex(codexCtx);
186
+ } catch (err) {
187
+ console.warn(`[pr-nanny] codex consult failed for ${worldId}: ${err.message} — defaulting to agree`);
188
+ }
189
+
190
+ if (verdict === 'push-back') {
191
+ prStateStore.set(worldId, { nanny_paused: true, nanny_pause_reason: 'codex_pushback' });
192
+ console.log(`[pr-nanny] Codex push-back for ${worldId} — pausing nanny`);
193
+ return;
194
+ }
195
+ if (verdict === 'rethink') {
196
+ prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'codex_rethink' });
197
+ console.log(`[pr-nanny] Codex rethink for ${worldId} — escalating`);
198
+ return;
199
+ }
200
+
201
+ // Dispatch fix
202
+ try {
203
+ await dispatchToWorld(worldId, prompt);
204
+ const now = new Date().toISOString();
205
+ prStateStore.set(worldId, {
206
+ nanny_dispatch_count: dispatchCount + 1,
207
+ nanny_first_dispatch_at: entry.nanny_first_dispatch_at ?? now,
208
+ nanny_last_dispatch_at: now,
209
+ nanny_last_dispatch_prompt: prompt,
210
+ });
211
+ console.log(`[pr-nanny] dispatched fix to ${worldId} (dispatch ${dispatchCount + 1}/${maxDispatches})`);
212
+ } catch (err) {
213
+ console.error(`[pr-nanny] dispatch failed for ${worldId}: ${err.message}`);
214
+ }
215
+ }
216
+
217
+ async function pollOnce() {
218
+ const ghToken = await getGhToken();
219
+ if (!ghToken) {
220
+ if (!warnedOnce) {
221
+ console.warn('[pr-nanny] no GH token — CI polling disabled');
222
+ warnedOnce = true;
223
+ }
224
+ return;
225
+ }
226
+
227
+ const worlds = prStateStore.getWorldsToWatch();
228
+ for (const { worldId, ...entry } of worlds) {
229
+ try {
230
+ await processWorld(worldId, entry, ghToken);
231
+ } catch (err) {
232
+ console.error(`[pr-nanny] processWorld error for ${worldId}: ${err.message}`);
233
+ }
234
+ }
235
+ }
236
+
237
+ function start() {
238
+ if (intervalId !== null) return;
239
+ // Immediate first poll
240
+ pollOnce().catch((err) => console.error('[pr-nanny] pollOnce error:', err.message));
241
+ intervalId = setInterval(() => {
242
+ pollOnce().catch((err) => console.error('[pr-nanny] pollOnce error:', err.message));
243
+ }, pollIntervalMs);
244
+ }
245
+
246
+ function stop() {
247
+ if (intervalId !== null) {
248
+ clearInterval(intervalId);
249
+ intervalId = null;
250
+ }
251
+ }
252
+
253
+ return { start, stop };
254
+ }
255
+
256
+ /**
257
+ * Default Codex consultation via the host-side `codex` CLI.
258
+ * @param {string} ctx
259
+ * @returns {Promise<'agree'|'push-back'|'rethink'>}
260
+ */
261
+ export async function defaultConsultCodex(ctx) {
262
+ try {
263
+ const { stdout } = await execFileAsync('codex', [
264
+ '--quiet',
265
+ '--model', 'codex-mini-latest',
266
+ `Adversarial review — is this a good idea? ${ctx} Reply with exactly one word: agree, push-back, or rethink.`,
267
+ ], { timeout: 30_000 });
268
+ const text = stdout.trim().toLowerCase();
269
+ if (text.startsWith('push')) return 'push-back';
270
+ if (text.startsWith('rethink')) return 'rethink';
271
+ return 'agree';
272
+ } catch {
273
+ return 'agree'; // fail-open: if codex unavailable, dispatch anyway
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Default dispatch: shell out to `olam dispatch <worldId> <prompt>`.
279
+ * @param {string} worldId
280
+ * @param {string} prompt
281
+ */
282
+ export async function defaultDispatchToWorld(worldId, prompt) {
283
+ await execFileAsync('olam', ['dispatch', worldId, prompt], { timeout: 60_000 });
284
+ }
@@ -55,6 +55,7 @@ import { composeWorldsSources } from './compose-worlds-sources.mjs';
55
55
  import { createWorldPrStateStore } from './world-pr-state.mjs';
56
56
  import { PlanOrchestrator } from './plan-orchestrator.mjs';
57
57
  import { createPrMergePoller } from './pr-merge-poller.mjs';
58
+ import { createPrNanny, defaultConsultCodex, defaultDispatchToWorld } from './pr-nanny.mjs';
58
59
  import { parse as parseYaml } from 'yaml';
59
60
  import { startWorldsDbReconciler } from './worlds-db-source.mjs';
60
61
  import { authSecretHint } from './auth-secret-hint.mjs';
@@ -346,6 +347,17 @@ const prPoller = createPrMergePoller({
346
347
  });
347
348
  prPoller.start();
348
349
 
350
+ // ── PR Nanny — watch all open PRs and dispatch fixes ──────────────────────
351
+
352
+ const prNanny = createPrNanny({
353
+ prStateStore,
354
+ getGhToken: resolveGhToken,
355
+ dispatchToWorld: defaultDispatchToWorld,
356
+ consultCodex: defaultConsultCodex,
357
+ pollIntervalMs: parseInt(process.env.OLAM_PR_NANNY_POLL_INTERVAL_MS ?? '60000', 10),
358
+ });
359
+ prNanny.start();
360
+
349
361
  // ── Worlds-DB reconcile loop ────────────────────────────────────
350
362
  //
351
363
  // When host-cp runs bare-node, the CLI's auto-register may not have fired
@@ -618,6 +630,11 @@ const server = http.createServer(async (req, res) => {
618
630
  pr_number: pr.pr_number ?? null,
619
631
  pr_url: pr.pr_url ?? null,
620
632
  pr_state: prState,
633
+ nanny_dispatch_count: pr.nanny_dispatch_count ?? 0,
634
+ nanny_paused: pr.nanny_paused ?? false,
635
+ nanny_escalated: pr.nanny_escalated ?? false,
636
+ nanny_loop_halted: pr.nanny_loop_halted ?? false,
637
+ nanny_external_blocker: pr.nanny_external_blocker ?? false,
621
638
  });
622
639
  }
623
640
 
@@ -1321,6 +1338,56 @@ const server = http.createServer(async (req, res) => {
1321
1338
  return;
1322
1339
  }
1323
1340
 
1341
+ // ── PR Nanny operator overrides ──────────────────────────────────────────
1342
+ //
1343
+ // POST /api/admin/nanny/:worldId/pause — pause nanny for this world
1344
+ // POST /api/admin/nanny/:worldId/resume — resume nanny for this world
1345
+ // POST /api/admin/nanny/:worldId/escalate — mark escalated, stop dispatching
1346
+ // GET /api/admin/nanny — dump nanny state for all worlds
1347
+ //
1348
+ // Calling-world identity: X-Olam-World-Id header validated against registry.
1349
+
1350
+ const nannyAction = /^\/api\/admin\/nanny\/([^/?#]+)\/(pause|resume|escalate)$/.exec(url.pathname);
1351
+ if (nannyAction && req.method === 'POST') {
1352
+ if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1353
+ const worldId = decodeURIComponent(nannyAction[1]);
1354
+ const action = nannyAction[2];
1355
+ // Validate calling-world identity when header present
1356
+ const callerWorldId = req.headers['x-olam-world-id'];
1357
+ if (callerWorldId && !(callerWorldId in WORLDS)) {
1358
+ return jsonReply(res, 403, { error: 'caller_world_not_registered' });
1359
+ }
1360
+ const existing = prStateStore.get(worldId);
1361
+ if (!existing) return jsonReply(res, 404, { error: 'world_not_found' });
1362
+ if (action === 'pause') {
1363
+ prStateStore.set(worldId, { nanny_paused: true, nanny_pause_reason: 'operator' });
1364
+ } else if (action === 'resume') {
1365
+ prStateStore.set(worldId, { nanny_paused: false, nanny_loop_halted: false, nanny_escalated: false });
1366
+ } else if (action === 'escalate') {
1367
+ prStateStore.set(worldId, { nanny_escalated: true, nanny_escalate_reason: 'operator' });
1368
+ }
1369
+ return jsonReply(res, 200, prStateStore.get(worldId));
1370
+ }
1371
+
1372
+ if (url.pathname === '/api/admin/nanny' && req.method === 'GET') {
1373
+ if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1374
+ const all = prStateStore.getAll();
1375
+ const nannyStates = Object.fromEntries(
1376
+ Object.entries(all).map(([id, e]) => [id, {
1377
+ nanny_dispatch_count: e.nanny_dispatch_count ?? 0,
1378
+ nanny_paused: e.nanny_paused ?? false,
1379
+ nanny_escalated: e.nanny_escalated ?? false,
1380
+ nanny_loop_halted: e.nanny_loop_halted ?? false,
1381
+ nanny_external_blocker: e.nanny_external_blocker ?? false,
1382
+ nanny_first_dispatch_at: e.nanny_first_dispatch_at ?? null,
1383
+ nanny_last_dispatch_at: e.nanny_last_dispatch_at ?? null,
1384
+ pr_url: e.pr_url ?? null,
1385
+ pr_state: e.pr_state ?? null,
1386
+ }]),
1387
+ );
1388
+ return jsonReply(res, 200, nannyStates);
1389
+ }
1390
+
1324
1391
  // DELETE /api/worlds/:worldId — immediate destroy
1325
1392
  const worldDestroyMatch = /^\/api\/worlds\/([^/?#]+)$/.exec(url.pathname);
1326
1393
  if (worldDestroyMatch && req.method === 'DELETE') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.65",
3
+ "version": "0.1.66",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"