@amenophis1er/foreman 0.1.14 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
5
  "keywords": [
6
6
  "claude",
package/src/notify.ts CHANGED
@@ -234,10 +234,14 @@ export function shape(env: Envelope, ctx: NotifyContext): Shaped | null {
234
234
  // --- money and completion -----------------------------------------
235
235
  case 'budget_alert':
236
236
  return { key: `budget:${env.runId}:${d.level}`, gate: 'budget',
237
- text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted' : 'Budget cap reached')}${runLine}\n${esc(clip(d.text))}${foot}` };
237
+ text: `${head(d.level === 'exceeded' ? 'Budget exceeded — run interrupted'
238
+ : d.level === 'warn' ? 'Budget warning — winding down' : 'Budget cap reached')}${runLine}\n${esc(clip(d.text))}${foot}` };
238
239
  case 'budget_stop':
239
240
  return { key: `stop:${env.runId}`, gate: 'budget',
240
241
  text: `${head('Run winding down')}${runLine}\n${esc(clip(d.reason))}${foot}` };
242
+ case 'mission_done_at_cap':
243
+ return { key: `donecap:${env.runId}`, gate: 'done',
244
+ text: `${head('Done, at the cap')}${runLine}\n${esc(clip(d.text))}${foot}` };
241
245
  case 'mission_incomplete':
242
246
  return { key: `incomplete:${env.runId}`, gate: 'done',
243
247
  text: `${head('Not done')}${runLine}\n${esc(clip(d.text))}${foot}` };
@@ -9,7 +9,7 @@ import {
9
9
  stalledWorkerReport, workerStatusBlock,
10
10
  watchRepeats, watchSilence, REPEAT_EXEMPT, observeToolUse,
11
11
  DEFAULT_ASK_TIMEOUT_MS, armAskTimeout, unattendedAnswer, unattendedDenyMessage,
12
- tokenCapLabel, stopReasonOf,
12
+ tokenCapLabel, stopReasonOf, budgetWarnDue, doneAtCap,
13
13
  } from './orchestrator.js';
14
14
  import { makePolicy, type PendingPermission } from './policy.js';
15
15
  import type { AgentEnv } from './provider.js';
@@ -1256,3 +1256,27 @@ test('stopReasonOf names the cap behind a capReached sentence', () => {
1256
1256
  assert.equal(stopReasonOf('TIME CAP REACHED: 240 minutes.'), 'time');
1257
1257
  assert.equal(stopReasonOf('TOKEN CAP REACHED: 20.1M tokens.'), 'tokens');
1258
1258
  });
1259
+
1260
+ test('budgetWarnDue: once past the threshold, and only while still under the cap', () => {
1261
+ assert.equal(budgetWarnDue(7.9, 10, 80), false);
1262
+ assert.equal(budgetWarnDue(8, 10, 80), true);
1263
+ assert.equal(budgetWarnDue(9.99, 10, 80), true);
1264
+ // At and past the cap the wind-down order speaks instead.
1265
+ assert.equal(budgetWarnDue(10, 10, 80), false);
1266
+ assert.equal(budgetWarnDue(12, 10, 80), false);
1267
+ // No threshold, a nonsensical one, or no cap: nothing to warn about.
1268
+ assert.equal(budgetWarnDue(9, 10, undefined), false);
1269
+ assert.equal(budgetWarnDue(9, 10, 0), false);
1270
+ assert.equal(budgetWarnDue(9, 10, 100), false);
1271
+ assert.equal(budgetWarnDue(9, 0, 80), false);
1272
+ });
1273
+
1274
+ test('doneAtCap: budget-stopped with every criterion ticked is done, and nothing else is', () => {
1275
+ const budget = { budgetStopped: true, wasInterrupted: false, usageLimited: false };
1276
+ assert.equal(doneAtCap(budget, []), true);
1277
+ assert.equal(doneAtCap(budget, ['screenshots saved']), false, 'an unticked box is not done');
1278
+ assert.equal(doneAtCap(budget, null), false, 'no DONE WHEN section says nothing either way');
1279
+ assert.equal(doneAtCap({ ...budget, wasInterrupted: true }, []), false, 'a human stop is not a finish');
1280
+ assert.equal(doneAtCap({ ...budget, usageLimited: true }, []), false, 'a usage limit is not a finish');
1281
+ assert.equal(doneAtCap({ ...budget, budgetStopped: false }, []), false);
1282
+ });
@@ -266,6 +266,31 @@ const DEFAULT_MAX_TURNS = 150;
266
266
  export const DEFAULT_MAX_TOKENS = 20_000_000;
267
267
 
268
268
  /** The cap behind a capReached() sentence, as the run record stores it. */
269
+ /**
270
+ * Is it time to tell the director to start winding down? True once spend
271
+ * crosses the warn threshold and while it is still under the cap — past the
272
+ * cap the wind-down order takes over, and a warning then would be noise.
273
+ */
274
+ export function budgetWarnDue(costUsd: number, budgetUsd: number, warnAt: number | undefined): boolean {
275
+ if (!Number.isFinite(warnAt as number) || (warnAt as number) <= 0 || (warnAt as number) >= 100) return false;
276
+ if (!(budgetUsd > 0)) return false;
277
+ return costUsd >= budgetUsd * ((warnAt as number) / 100) && costUsd < budgetUsd;
278
+ }
279
+
280
+ /**
281
+ * A run the budget stopped, whose own DONE WHEN criteria are all ticked, is
282
+ * done: it reached its criteria and then reached its limit, in that order.
283
+ * Only for the budget — a human interrupt or a usage limit says nothing
284
+ * about the work — and only when the doc actually had criteria to tick.
285
+ */
286
+ export function doneAtCap(
287
+ flags: { budgetStopped: boolean; wasInterrupted: boolean; usageLimited: boolean },
288
+ unmet: string[] | null,
289
+ ): boolean {
290
+ if (!flags.budgetStopped || flags.wasInterrupted || flags.usageLimited) return false;
291
+ return Array.isArray(unmet) && unmet.length === 0;
292
+ }
293
+
269
294
  export function stopReasonOf(cap: string): 'budget' | 'turns' | 'time' | 'tokens' {
270
295
  if (cap.startsWith('TURN')) return 'turns';
271
296
  if (cap.startsWith('TIME')) return 'time';
@@ -1080,6 +1105,7 @@ export class MissionRun {
1080
1105
  // overrun quiet.
1081
1106
  this.budgetNoticeSent = false;
1082
1107
  this.budgetKillSent = false;
1108
+ this.budgetWarnSent = false;
1083
1109
  this.budgetStopped = false;
1084
1110
  }
1085
1111
  }
@@ -1332,6 +1358,23 @@ export class MissionRun {
1332
1358
  // the one lie a mission runner cannot afford. Downgrading to
1333
1359
  // 'interrupted' is also the useful answer: it is what makes the run
1334
1360
  // resumable rather than closed.
1361
+ // A mission whose own record says every criterion is verified is done,
1362
+ // even if the cap ended the turn it was writing its report in. Calling
1363
+ // that 'interrupted' told the fleet a finished mission had failed, and
1364
+ // invited a resume that spent more to rewrite a report already on disk.
1365
+ // The stop reason stays on the record, so nothing is hidden.
1366
+ if (this.meta.status === 'interrupted' && this.budgetStopped
1367
+ && !this.wasInterrupted && !this.usageLimited) {
1368
+ const unmet = await this.unmetCriteria();
1369
+ if (doneAtCap({ budgetStopped: this.budgetStopped, wasInterrupted: this.wasInterrupted, usageLimited: this.usageLimited }, unmet)) {
1370
+ this.meta.status = 'done';
1371
+ this.emit('mission_done_at_cap', {
1372
+ costUsd: this.meta.costUsd, budgetUsd: this.meta.budgetUsd, reason: this.meta.stopReason,
1373
+ text: 'Every DONE WHEN criterion was verified before the cap ended the run, so this mission is done. ' +
1374
+ 'It stopped at its limit rather than finishing under it — the report may be shorter than usual.',
1375
+ });
1376
+ }
1377
+ }
1335
1378
  if (this.meta.status === 'done') {
1336
1379
  const unmet = await this.unmetCriteria();
1337
1380
  if (unmet?.length) {
@@ -1482,6 +1525,7 @@ export class MissionRun {
1482
1525
 
1483
1526
  private budgetNoticeSent = false;
1484
1527
  private budgetKillSent = false;
1528
+ private budgetWarnSent = false;
1485
1529
 
1486
1530
  /**
1487
1531
  * Keeps an "always allow" grant out of `git status`.
@@ -1652,6 +1696,26 @@ export class MissionRun {
1652
1696
  void this.interrupt();
1653
1697
  return;
1654
1698
  }
1699
+ // Before the fence, a nudge. A director that only learns of the cap when
1700
+ // it hits it does its verification and its report inside the one turn it
1701
+ // has left — which is how two missions that had finished their work were
1702
+ // cut mid-report and read as failures. Warn while there is still room to
1703
+ // wind down deliberately.
1704
+ if (!this.budgetWarnSent && budgetWarnDue(costUsd, budgetUsd, this.meta.budgetWarnAt)) {
1705
+ this.budgetWarnSent = true;
1706
+ const pct = Math.round((costUsd / budgetUsd) * 100);
1707
+ this.emit('budget_alert', {
1708
+ level: 'warn', costUsd, budgetUsd,
1709
+ text: `${pct}% of the budget spent ($${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}) — director told to start verifying.`,
1710
+ });
1711
+ this.directorInput?.push(
1712
+ '[BUDGET — automated notice]\n' +
1713
+ `You have spent $${costUsd.toFixed(2)} of the $${budgetUsd.toFixed(2)} cap (${pct}%). ` +
1714
+ 'Start winding down now: finish or stop the work in flight, do not begin anything you ' +
1715
+ 'cannot complete and verify within what is left, and get your verification and MISSION.md ' +
1716
+ 'up to date. At the cap you get one final turn, and at 125% the run is interrupted.',
1717
+ );
1718
+ }
1655
1719
  if (!this.budgetNoticeSent && costUsd >= budgetUsd) {
1656
1720
  this.budgetNoticeSent = true;
1657
1721
  this.emit('budget_alert', {
package/src/server.ts CHANGED
@@ -1648,6 +1648,8 @@ async function effectiveSettings(projectId: string): Promise<{
1648
1648
  directorProviderId?: string; workerProviderId?: string;
1649
1649
  /** In a repository, each mission runs on a branch of its own (default on). */
1650
1650
  gitBranchPerMission: boolean;
1651
+ /** Percent of the cap at which the director is told to start verifying (default 80). */
1652
+ budgetWarnAt: number;
1651
1653
  }> {
1652
1654
  const s = await store.readSettings()
1653
1655
  .catch(() => ({ global: {}, projects: {} as Record<string, object> }));
@@ -1669,6 +1671,10 @@ async function effectiveSettings(projectId: string): Promise<{
1669
1671
  directorProviderId: str(p.directorProviderId ?? g.directorProviderId),
1670
1672
  workerProviderId: str(p.workerProviderId ?? g.workerProviderId),
1671
1673
  gitBranchPerMission: (p.gitBranchPerMission ?? g.gitBranchPerMission) !== false,
1674
+ budgetWarnAt: (() => {
1675
+ const raw = Number(p.budgetWarnAt ?? g.budgetWarnAt);
1676
+ return Number.isFinite(raw) && raw > 0 && raw < 100 ? raw : 80;
1677
+ })(),
1672
1678
  };
1673
1679
  }
1674
1680
 
@@ -1708,6 +1714,7 @@ async function startRun(
1708
1714
  workerProviderId: roleProviders.worker ?? settings.workerProviderId,
1709
1715
  ownerPid: process.pid,
1710
1716
  browserTools: browserTools || undefined,
1717
+ budgetWarnAt: settings.budgetWarnAt,
1711
1718
  toolPolicy: settings.toolPolicy,
1712
1719
  autoAllowReadOnly: settings.autoAllowReadOnly,
1713
1720
  // Frozen at dispatch: a later change to the project or the server default
package/src/types.ts CHANGED
@@ -330,6 +330,11 @@ export interface RunMeta {
330
330
  * to offer the one action that helps — raise the budget and resume — and
331
331
  * a resume clears it. Absent when the run ended for any other reason.
332
332
  */
333
+ /**
334
+ * Percent of the cap at which the director is told to start winding down,
335
+ * frozen at dispatch like the cap itself. Absent means the default.
336
+ */
337
+ budgetWarnAt?: number;
333
338
  stopReason?: 'budget' | 'turns' | 'time' | 'tokens';
334
339
  /** When the run's record was frozen (MISSION.md and deck copied beside it); absent on older runs. */
335
340
  snapshotAt?: number;