atris 3.71.1 → 3.71.2

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,6 +1,6 @@
1
1
  ---
2
2
  name: campaigns
3
- description: "Run an Atris campaign: a long goal with a judge that fails today, a budget, and members in seats, worked by missions until the judge passes. Use when the goal takes many runs, needs several members, or must spend under a cap. Triggers on: run a campaign, long goal, big goal, keep going until it passes, campaign, judge, budget, atris campaign."
3
+ description: "Run an Atris campaign: a long goal (hours to days) with a judge that fails today and members in seats, worked by missions until the judge passes. Use only when the path is unknown and takes many tries; a quick fix or a single mission is not a campaign. Triggers on: run a campaign, long goal, big goal, keep going until it passes, campaign, judge, budget, atris campaign."
4
4
  version: 1.0.0
5
5
  tags:
6
6
  - campaign
@@ -12,14 +12,18 @@ tags:
12
12
 
13
13
  # Campaigns
14
14
 
15
- A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions under a budget until the judge passes, the money runs out, or a person has to decide something.
15
+ A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions until the judge passes, a budget (cloud only) runs out, or a person has to decide something.
16
16
 
17
- ## Campaign or mission
17
+ ## Size it first
18
18
 
19
- - **Mission**: one member, one objective, one check. Done in a run or a few ticks.
20
- - **Campaign**: the goal needs many missions, more than one member, or a spending cap. A campaign decides what to do next; its missions do the work.
19
+ Ask one question: do you already know the steps?
21
20
 
22
- If one `atris mission start` would finish it, use a mission.
21
+ - **Quick fix** (minutes): you know the file and the fix. Edit it, run the tests that cover it, ship. No mission, no judge.
22
+ - **Mission** (an hour or a few): you know what done looks like and roughly how. One `atris mission start`, or a few across members.
23
+ - **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. A campaign decides what to do next; its missions do the work.
24
+ - **Purpose** (months to years): a standing campaign (`--standing`). Campaigns run under it (`--parent`), and each one's lessons feed the next.
25
+
26
+ A campaign is a vision, not a fix. If one mission would finish it, use a mission; if you could write the diff in ten minutes, write it. Measured 2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes.
23
27
 
24
28
  ## The five commands
25
29
 
package/atris.md CHANGED
@@ -131,6 +131,7 @@ Six primitives, one sentence each:
131
131
  - **The Mission** (MISSION.md): the enduring purpose. Never ends. Sits at the top and helps members pick goals.
132
132
  - **Goals** (endgame, member goals): direction derived from the Mission. A goal points; it does not commit.
133
133
  - **A mission run** (`atris mission`): a goal that signed a contract: objective + owner + verifier + stop condition. Ends in hours or days. Any engine can tick it.
134
+ - **A campaign** (`atris campaign`): a measurable goal with a judge that fails today, worked by many mission runs until it passes. Hours to days.
134
135
  - **Tasks** (`atris task`): the smallest provable units. Receipts, then human accept.
135
136
  - **Members** (`atris member`): the WHO. A role with memory and judgment doing one bounded, proven slice at a time.
136
137
  - **Loops** (`atris loops`): the WHEN. Clocks that never finish, only fire. Health is exit 0 on cadence.
@@ -236,6 +237,14 @@ Before picking up work, decide scope:
236
237
  - single project → route to that project's `atris/team/` and `atris task` queue
237
238
  - crosses projects → route to `atris/team/cross-project-architect/` and plan the dependency order first
238
239
 
240
+ Then size it, before any tool runs. Ask one question: do I already know the steps?
241
+ - **Quick fix** (minutes): you know the file and the fix. Do it, run the tests that cover it, ship. No mission, no judge.
242
+ - **Mission** (an hour or a few): you know what done looks like and roughly how. One mission run, or a few across members (builder builds, validator checks).
243
+ - **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. `atris campaign` with a judge that fails today; its missions do the work.
244
+ - **Purpose** (months to years): a standing campaign under the Mission. Its judge runs on a schedule, campaigns run under it, and each one's lessons and scored record feed the next. This is the self-improving loop.
245
+
246
+ Never size up for show. A fix run as a campaign wastes time and inference and leaves bad data (2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes). Sizing is judgment, not a command: `atris wish` is for imagining, not for sizing.
247
+
239
248
  The human is the constructor. You multiply. Handoff fidelity lives in the files, not in context.
240
249
 
241
250
  ## next
@@ -11,12 +11,12 @@ const path = require('path');
11
11
  const { spawn } = require('child_process');
12
12
  const { readFlag } = require('../lib/arg-parser');
13
13
  const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign-schema');
14
- const { decide } = require('../lib/campaign-core');
14
+ const { decide, paysDollars } = require('../lib/campaign-core');
15
15
  const { campaignDbPath, getCampaign, listCampaigns, newCampaignId, openCampaignStore } = require('../lib/campaign-store');
16
16
  const { findHoldout, moveHoldoutBack, moveHoldoutIn, scrub } = require('../lib/campaign-holdout');
17
17
  const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
18
18
  const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
19
- const { campaignHome, changedProtected, checkoutTop, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
19
+ const { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
20
20
 
21
21
  const JUDGE_TIMEOUT_SECONDS = 15 * 60;
22
22
  // Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
@@ -211,6 +211,13 @@ function keepJudgeScript(judge, folder) {
211
211
  return { from: file, judge: shellQuote(copy) };
212
212
  }
213
213
 
214
+ // The commit saved on a campaign's start, or null when it started outside git.
215
+ function savedStartCommit(store, campaignId) {
216
+ if (!campaignId) return null;
217
+ const start = store.read(campaignId).find((event) => event.type === 'campaign_started');
218
+ return (start && start.data && start.data.start_commit) || null;
219
+ }
220
+
214
221
  function makeContext(args, deps) {
215
222
  const asJson = args.includes('--json');
216
223
  const store = deps.store || openCampaignStore(process.cwd());
@@ -220,10 +227,15 @@ function makeContext(args, deps) {
220
227
  // Held-out files live beside the campaign store, outside every checkout.
221
228
  const holdoutRoot = deps.holdoutRoot || path.dirname(store.path || campaignDbPath());
222
229
  // A campaign with a held-out file hands its path to the judge, and nothing
223
- // the judge prints from it reaches the store.
224
- const judge = async (command, timeout, campaignId) => {
230
+ // the judge prints from it reaches the store. A campaign started in a git
231
+ // checkout hands the judge the commit it started from. Start passes its
232
+ // commit in, since nothing is saved yet.
233
+ const judge = async (command, timeout, campaignId, startCommit = savedStartCommit(store, campaignId)) => {
225
234
  const holdout = campaignId ? findHoldout(holdoutRoot, campaignId) : null;
226
- const reading = await runJudge(command, timeout, holdout ? { ...process.env, ATRIS_CAMPAIGN_HOLDOUT: holdout } : process.env);
235
+ const env = { ...process.env };
236
+ if (holdout) env.ATRIS_CAMPAIGN_HOLDOUT = holdout;
237
+ if (startCommit) env.ATRIS_CAMPAIGN_START_COMMIT = startCommit;
238
+ const reading = await runJudge(command, timeout, env);
227
239
  if (!holdout) return reading;
228
240
  return { ...reading, summary: scrub(reading.summary, holdout), output_tail: scrub(reading.output_tail, holdout) };
229
241
  };
@@ -250,6 +262,14 @@ function requireCampaign(ctx, id) {
250
262
  return { view };
251
263
  }
252
264
 
265
+ // On a subscription the budget is still a cap, measured at API prices, so the
266
+ // person sees how much of it is used without a dollar figure they never paid.
267
+ function spentText(view, paid) {
268
+ if (paid) return `${money(view.budget.spent)} spent of ${money(view.budget.total)}`;
269
+ const used = view.budget.total > 0 ? Math.round((view.budget.spent / view.budget.total) * 100) : 0;
270
+ return `on your subscription, nothing spent · ${used}% of the budget used`;
271
+ }
272
+
253
273
  function renderList(views) {
254
274
  if (!views.length) {
255
275
  return 'No campaigns yet. Start one:\n atris campaign "<goal as a sentence>" --judge "<command that fails today>"';
@@ -259,7 +279,7 @@ function renderList(views) {
259
279
  for (const view of sorted) {
260
280
  const running = view.missions.filter((m) => m.status === 'running').length;
261
281
  lines.push(`${clip(view.title)} (${view.id})`);
262
- const spend = noLimit(view) ? '' : ` · ${money(view.budget.spent)} of ${money(view.budget.total)}`;
282
+ const spend = noLimit(view) ? '' : ` · ${spentText(view, paysDollars(view))}`;
263
283
  lines.push(` ${view.state.replace(/_/g, ' ')}${spend} · ${running} mission${running === 1 ? '' : 's'} running · answer ${view.answer.confidence}`);
264
284
  lines.push(` next: ${nextMove(view).text}`);
265
285
  }
@@ -274,14 +294,17 @@ function renderShow(view) {
274
294
  const last = view.last_judge;
275
295
  row('judge', `${view.judge}${last ? ` · last run ${last.passed ? 'passed' : 'failed'} at ${last.at}` : ' · never run'}${view.judge_due && last ? ' · due again' : ''}`);
276
296
  row('answer', `${view.answer.confidence}${view.answer.text ? `: ${view.answer.text}` : ''}`);
277
- if (!noLimit(view)) row('budget', `${money(view.budget.spent)} spent of ${money(view.budget.total)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left`);
297
+ if (!noLimit(view)) {
298
+ const paid = paysDollars(view);
299
+ row('budget', paid ? `${spentText(view, paid)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left` : spentText(view, paid));
300
+ }
278
301
  row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
279
302
  if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
280
303
  if (view.parent) row('parent', view.parent);
281
304
  row('seats', view.seats.length ? view.seats.map((s) => (s.role ? `${s.member} (${s.role})` : s.member)).join(', ') : 'none');
282
305
  row('missions', view.missions.length ? '' : 'none');
283
306
  for (const m of view.missions) {
284
- const cost = m.cost_usd === null ? '' : ` · ${money(m.cost_usd)}`;
307
+ const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
285
308
  lines.push(` ${m.id.padEnd(22)} ${m.status.padEnd(8)} ${m.member || '-'}${m.outcome ? ` · ${m.outcome}` : ''}${cost}`);
286
309
  }
287
310
  row('asks', view.asks.length ? '' : 'none');
@@ -352,7 +375,8 @@ async function cmdStart(args, ctx) {
352
375
 
353
376
  const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
354
377
  if (!ctx.asJson) console.error(`running the judge first: ${judge}`);
355
- const reading = await ctx.judge(judge, timeout, id);
378
+ const startCommit = headCommit(process.cwd());
379
+ const reading = await ctx.judge(judge, timeout, id, startCommit);
356
380
  if (reading.passed && !standing) {
357
381
  moveHoldoutBack(held);
358
382
  return ctx.fail(`already done: the judge passes before any work (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
@@ -371,6 +395,7 @@ async function cmdStart(args, ctx) {
371
395
  if (bet && !wrongIf) startData.bet = bet;
372
396
  if (standing) startData.standing = true;
373
397
  if (basis) startData.basis = basis;
398
+ if (startCommit) startData.start_commit = startCommit;
374
399
  ctx.append(id, 'campaign_started', startData);
375
400
  if (bet && wrongIf) ctx.append(id, 'bet_set', { bet, wrong_if: wrongIf });
376
401
  if (parent) ctx.append(id, 'attached', { parent_id: parent });
@@ -6989,18 +6989,34 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
6989
6989
  // before the tick fires. Same tag-plus-text routing the task lane uses
6990
6990
  // (CLI-1189). An operator override is explicit: protected_lane_ack in the
6991
6991
  // mission metadata, set by a human, or ATRIS_ALLOW_PROTECTED_MISSION=1.
6992
- function missionProtectedLaneHold(mission) {
6992
+ function missionProtectedLaneHold(mission, root = process.cwd()) {
6993
6993
  if (process.env.ATRIS_ALLOW_PROTECTED_MISSION === '1') return null;
6994
6994
  if (mission?.metadata?.protected_lane_ack) return null;
6995
6995
  const { DENIED_TAGS } = require('../lib/fleet');
6996
6996
  const { declaredProtectedLane, sniffedProtectedLane } = require('../lib/auto-accept-certified');
6997
6997
  const lane = String(mission?.lane || '').toLowerCase();
6998
- if (DENIED_TAGS.includes(lane)) return { pause_reason: `protected-lane-${lane}` };
6999
6998
  const probe = { title: mission?.objective, objective: mission?.stop_condition, metadata: mission?.metadata || {} };
7000
- if (declaredProtectedLane(probe)) return { pause_reason: 'protected-lane-declared' };
7001
- const sniffed = sniffedProtectedLane(probe);
7002
- if (sniffed) return { pause_reason: `protected-lane-${sniffed.lane}` };
7003
- return null;
6999
+ let hold = null;
7000
+ if (DENIED_TAGS.includes(lane)) hold = { pause_reason: `protected-lane-${lane}`, lane };
7001
+ else if (declaredProtectedLane(probe)) hold = { pause_reason: 'protected-lane-declared', lane: 'declared' };
7002
+ else {
7003
+ const sniffed = sniffedProtectedLane(probe);
7004
+ if (sniffed) hold = { pause_reason: `protected-lane-${sniffed.lane}`, lane: sniffed.lane };
7005
+ }
7006
+ // Inside a campaign, a person may have allowed this lane by answering the
7007
+ // campaign's ask. Only a person's answer closes an ask, so it counts as the ack.
7008
+ if (hold && mission?.campaign_id && campaignApprovedLane(root, mission.campaign_id, hold.lane)) return null;
7009
+ return hold ? { pause_reason: hold.pause_reason } : null;
7010
+ }
7011
+
7012
+ function campaignApprovedLane(root, campaignId, lane) {
7013
+ try {
7014
+ const { openCampaignStore } = require('../lib/campaign-store');
7015
+ const { approvedLanes } = require('../lib/campaign-lanes');
7016
+ return approvedLanes(openCampaignStore(root).read(campaignId)).has(lane);
7017
+ } catch {
7018
+ return false;
7019
+ }
7004
7020
  }
7005
7021
 
7006
7022
  function runnerUsesCallerSession(runner) {
@@ -10342,7 +10358,7 @@ async function executeMissionRunTicksPhase(context) {
10342
10358
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
10343
10359
  if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
10344
10360
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
10345
- const protectedHold = missionProtectedLaneHold(mission);
10361
+ const protectedHold = missionProtectedLaneHold(mission, cwd);
10346
10362
  if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
10347
10363
 
10348
10364
  const tickIdx = Number(mission.last_tick_index || 0) + 1;
@@ -9,6 +9,7 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { paysDollars } = require('./campaign-core');
12
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
14
 
14
15
  const LESSON_LINES_FOR_PLANNING = 30;
@@ -44,8 +45,14 @@ function roundSummary(before, after) {
44
45
  return { kept, failed, vetoed, spent, fresh };
45
46
  }
46
47
 
48
+ // On a subscription the round is told without dollars, since none were paid.
49
+ function roundTally(view, summary) {
50
+ return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
51
+ }
52
+
47
53
  function reviewPrompt(view, summary, lessons) {
48
- const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
54
+ const paid = paysDollars(view);
55
+ const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${paid && m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
49
56
  return [
50
57
  'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
51
58
  'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
@@ -53,7 +60,7 @@ function reviewPrompt(view, summary, lessons) {
53
60
  `Campaign: ${view.title}`,
54
61
  `Judge: ${view.judge}`,
55
62
  `Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
56
- `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
63
+ `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start${paid ? `, $${summary.spent} spent` : ''}`,
57
64
  'Missions this round:',
58
65
  missions,
59
66
  '',
@@ -88,9 +95,9 @@ function makeCadence(root, loop, options = {}, deps = {}) {
88
95
  fs.mkdirSync(path.dirname(file), { recursive: true });
89
96
  if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
90
97
  const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
91
- const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
98
+ const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
92
99
  fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
93
- return { summary, lessons: written };
100
+ return { summary, lessons: written, view: after };
94
101
  }
95
102
 
96
103
  async function run(id) {
@@ -111,8 +118,8 @@ function makeCadence(root, loop, options = {}, deps = {}) {
111
118
  }
112
119
  const breakStarted = now();
113
120
  log(`round ${round}: review`);
114
- const { summary, lessons } = await review(id, before);
115
- log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
121
+ const { summary, lessons, view: reviewed } = await review(id, before);
122
+ log(` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
116
123
  const view = getCampaign(store, id);
117
124
  last = { reason: stopped || 'work window ended', round };
118
125
  // Finished, out of money, or waiting on a person: no point in another round.
@@ -282,6 +282,9 @@ function fold(events) {
282
282
  bet: s.bet,
283
283
  parent: s.parent,
284
284
  home: s.home,
285
+ // Only when the start event names one, so the shared golden views stay as
286
+ // pinned; no basis means the person's own engines.
287
+ ...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
285
288
  created_from: d.created_from === undefined ? null : d.created_from,
286
289
  seats: s.seats,
287
290
  state,
@@ -327,4 +330,12 @@ function decide(view) {
327
330
  return { action: 'propose', reason: 'no missions queued' };
328
331
  }
329
332
 
330
- module.exports = { cents, compareEvents, decide, fold };
333
+ // Spend is real dollars only when billing is API or Atris runs the campaign on
334
+ // its own engines (basis atris_cloud). Otherwise the work ran on the person's own
335
+ // subscriptions: they paid nothing, and cost_usd stays as the API-price budget meter.
336
+ function paysDollars(view) {
337
+ if (process.env.ATRIS_BILLING === 'api') return true;
338
+ return Boolean(view && view.basis === 'atris_cloud');
339
+ }
340
+
341
+ module.exports = { cents, compareEvents, decide, fold, paysDollars };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // Protected lanes inside a campaign. A mission that touches billing, security,
4
+ // deploys, or customers pauses before its first tick (missionProtectedLaneHold).
5
+ // Inside a campaign that pause becomes an ask; a person answering yes approves
6
+ // that one lane for that one campaign. The approval lives in the campaign's own
7
+ // event log, where only a person's answer closes an ask.
8
+
9
+ const { isHumanActor } = require('./campaign-schema');
10
+
11
+ const LANE_ASK_KIND = 'protected_lane';
12
+ const YES = /^\s*(y|yes|ok|okay|approve|approved|allow|go|sure)\b/i;
13
+ const PAUSE_LINE = /paused: protected-lane-([a-z]+)/i;
14
+
15
+ // The lane a paused `atris mission run` reported, or null.
16
+ function pausedLane(output) {
17
+ const match = String(output || '').match(PAUSE_LINE);
18
+ return match ? match[1].toLowerCase() : null;
19
+ }
20
+
21
+ // Lanes a person approved, from a campaign's events.
22
+ function approvedLanes(events) {
23
+ const asked = new Map();
24
+ const lanes = new Set();
25
+ for (const event of events || []) {
26
+ const data = event && event.data ? event.data : {};
27
+ if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) asked.set(data.ask_id, data.lane);
28
+ if (event.type === 'ask_answered' && asked.has(data.ask_id) && isHumanActor(event.actor) && YES.test(String(data.answer || ''))) {
29
+ lanes.add(asked.get(data.ask_id));
30
+ }
31
+ }
32
+ return lanes;
33
+ }
34
+
35
+ // True when this campaign already has an open ask for the lane, so the loop asks once.
36
+ function laneAskOpen(events, lane) {
37
+ const open = new Set();
38
+ for (const event of events || []) {
39
+ const data = event && event.data ? event.data : {};
40
+ if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane === lane) open.add(data.ask_id);
41
+ if (event.type === 'ask_answered') open.delete(data.ask_id);
42
+ }
43
+ return open.size > 0;
44
+ }
45
+
46
+ function laneQuestion(lane, member, objective) {
47
+ const what = String(objective || '').replace(/\s+/g, ' ').trim();
48
+ const short = what.length > 140 ? `${what.slice(0, 139)}…` : what;
49
+ return `${member}'s mission touches ${lane}: "${short}". Missions that touch ${lane} wait for you. Let this campaign work on ${lane}? Answer yes to allow it.`;
50
+ }
51
+
52
+ module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion };
@@ -12,6 +12,7 @@ const { decide } = require('./campaign-core');
12
12
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
13
  const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
14
14
  const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
+ const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion } = require('./campaign-lanes');
15
16
 
16
17
  const TERMINAL = new Set(['stopped', 'complete']);
17
18
 
@@ -86,17 +87,37 @@ async function askOwner(root, view, prompt) {
86
87
  }
87
88
 
88
89
  // Run one atris command and resolve with its exit code and output.
90
+ // A live step an engine prints while a mission runs: "<member> <hh:mm> <what it is doing>".
91
+ const ACTIVITY_LINE = /^\s*[a-z0-9][a-z0-9-]*\s+\d{1,2}:\d{2}\s{2,}\S/i;
92
+
89
93
  function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
90
94
  return new Promise((resolve) => {
91
95
  const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
92
96
  let out = '';
93
97
  let err = '';
94
- child.stdout.on('data', (chunk) => {
95
- out += chunk;
96
- if (onLine) String(chunk).split('\n').filter(Boolean).forEach(onLine);
98
+ // Whole lines only: a chunk can end mid-line. Live engine steps arrive on
99
+ // stderr (member-progress reportProgress), so both streams are read.
100
+ const reader = () => {
101
+ let partial = '';
102
+ return {
103
+ push(chunk) {
104
+ if (!onLine) return;
105
+ const lines = (partial + String(chunk)).split('\n');
106
+ partial = lines.pop();
107
+ lines.filter(Boolean).forEach(onLine);
108
+ },
109
+ end() { if (onLine && partial) onLine(partial); },
110
+ };
111
+ };
112
+ const stdout = reader();
113
+ const stderr = reader();
114
+ child.stdout.on('data', (chunk) => { out += chunk; stdout.push(chunk); });
115
+ child.stderr.on('data', (chunk) => { err += chunk; stderr.push(chunk); });
116
+ child.on('close', (code) => {
117
+ stdout.end();
118
+ stderr.end();
119
+ resolve({ code, out, err });
97
120
  });
98
- child.stderr.on('data', (chunk) => { err += chunk; });
99
- child.on('close', (code) => resolve({ code, out, err }));
100
121
  });
101
122
  }
102
123
 
@@ -111,7 +132,7 @@ function makeLoop(root, options = {}, deps = {}) {
111
132
  const runner = options.runner || 'auto';
112
133
  const actor = options.actor || 'mission-lead';
113
134
  const ask = deps.ask || askOwner;
114
- const cli = deps.cli || ((args) => runCli(args, { cwd: root }));
135
+ const cli = deps.cli || ((args, extra = {}) => runCli(args, { cwd: root, ...extra }));
115
136
  const judge = deps.judge;
116
137
 
117
138
  const view = (id) => getCampaign(store, id);
@@ -168,8 +189,22 @@ function makeLoop(root, options = {}, deps = {}) {
168
189
  return { mission_id: work.mission_id, started: false };
169
190
  }
170
191
  log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
171
- const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass']);
192
+ // The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
193
+ // the campaign's own output, so a screen watching the run can show the work.
194
+ const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass'], { onLine: (line) => { if (ACTIVITY_LINE.test(line)) log(` ${line.trim()}`); } });
172
195
  const after = view(id).missions.find((m) => m.id === work.mission_id);
196
+ // A mission in a protected lane (billing, security, deploy, customer) pauses
197
+ // before its first tick. Ask a person once per lane instead of failing quietly.
198
+ const lane = after && after.status !== 'ended' ? pausedLane(`${ran.out || ''}\n${ran.err || ''}`) : null;
199
+ if (lane) {
200
+ if (!laneAskOpen(store.read(id), lane)) {
201
+ const askId = `a-${crypto.randomBytes(3).toString('hex')}`;
202
+ append(id, 'ask_opened', { ask_id: askId, question: laneQuestion(lane, work.member, work.objective), mission_id: work.mission_id, kind: LANE_ASK_KIND, lane }, current.owner);
203
+ }
204
+ await cli(['mission', 'stop', work.mission_id, '--reason', `campaign loop: waiting for a person to allow the ${lane} lane`]);
205
+ log(` ${work.mission_id} needs you: it touches ${lane}`);
206
+ return { mission_id: work.mission_id, started: true, outcome: null, needs: lane };
207
+ }
173
208
  if (after && after.status !== 'ended') {
174
209
  // The run stopped without finishing (tick cap, error): close it so its
175
210
  // reserve comes back and its cost is counted.
@@ -243,4 +278,4 @@ function makeLoop(root, options = {}, deps = {}) {
243
278
  return { step, run, propose };
244
279
  }
245
280
 
246
- module.exports = { askOwner, makeLoop, parseProposals, proposalPrompt };
281
+ module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, runCli };
@@ -29,6 +29,12 @@ function protectFile(home, campaignId) {
29
29
  return path.join(home, 'campaigns', campaignId, 'protect.json');
30
30
  }
31
31
 
32
+ // The commit a checkout sits on, or null outside git or before the first commit.
33
+ function headCommit(root) {
34
+ const head = git(root, ['rev-parse', 'HEAD']);
35
+ return head.ok ? head.out : null;
36
+ }
37
+
32
38
  // The commit and checkout-relative paths to save, or an error in plain words.
33
39
  function planProtect(root, paths) {
34
40
  const topDir = checkoutTop(root);
@@ -72,4 +78,4 @@ function changedProtected(root, saved) {
72
78
  return { changed: [...new Set(changed)].sort() };
73
79
  }
74
80
 
75
- module.exports = { campaignHome, changedProtected, checkoutTop, planProtect, protectFile, readProtect, saveProtect };
81
+ module.exports = { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, protectFile, readProtect, saveProtect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.71.1",
3
+ "version": "3.71.2",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {