bareloop 0.37.0 → 0.39.0

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.
@@ -52,6 +52,7 @@ import { closeJudges } from '../kinds.js';
52
52
  import { redactSecrets } from '../validate.js';
53
53
  import { tallyCalls } from '../text.js';
54
54
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
55
+ import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, redsRecord, calibrationSummary } from '../authorreadout.js';
55
56
  import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
56
57
 
57
58
  /**
@@ -132,13 +133,24 @@ const SOURCE_CHECK_CODES = new Set([
132
133
  'source-file-oversize', 'source-changed-after-scan', 'source-untracked-in-repo',
133
134
  ]);
134
135
 
135
- /** the confirm turn's own fixed round cap (`src/authorflow.js`'s
136
- * `runConfirmTurn`: `for (let round = 1; round <= 2; round += 1)`) — named
137
- * here, once, rather than re-guessed at every `onPhase('confirm-round')`
138
- * below. Not itself exported from the library as a named constant (it is
139
- * the loop bound the confirm turn's own doc calls "D3" / "ruling 5"); this is
140
- * this file's one place that would need editing if that cap ever changed. */
141
- const CONFIRM_ROUND_CAP = 2;
136
+ /**
137
+ * The confirm turn's plan as the chat shows it: ONE owner of the text (hamr, 2026-10-09: the one-blob "Plan: ...
138
+ * Checks: ... Not checked: ..." was unreadable). Each section is a heading on its own line, its content on the next,
139
+ * and a blank line between sections. `#NOT CHECKED:` and `#QUESTIONS:` appear only when there is something to say.
140
+ * The CLI composes its own plan screen (src/authorrun.js) and shares no text with this one.
141
+ * @param {{goal?: string, checks?: string[], notChecked?: string[], questions?: string[]}} p
142
+ * @returns {string}
143
+ */
144
+ export function planText(p) {
145
+ const sections = [
146
+ `#PLAN:\n${JSON.stringify(p.goal ?? '')}`,
147
+ `#CHECKS:\n${(p.checks ?? []).join(' · ') || '(none)'}`,
148
+ ];
149
+ if ((p.notChecked ?? []).length) sections.push(`#NOT CHECKED:\n${(p.notChecked ?? []).join(' · ')}`);
150
+ if ((p.questions ?? []).length) sections.push(`#QUESTIONS:\n${(p.questions ?? []).join(' · ')}`);
151
+ sections.push('Sign & run to confirm this plan, or Revise to edit the boxes and draft again.');
152
+ return sections.join('\n\n');
153
+ }
142
154
 
143
155
  /** @param {string} name @returns {boolean} kebab-case slug, same rule `job.js`'s `SLUG_RE` enforces */
144
156
  function isSlug(name) {
@@ -304,7 +316,7 @@ export function validateJobCard(card, opts = {}) {
304
316
  /**
305
317
  * ONE session's engine — created already RUNNING (the caller awaits nothing;
306
318
  * the pipeline drives itself, reporting through `state`). `state.pendingAsk`
307
- * is the ONE thing a person answers next; `answer()`/`send()`/`revise()`/
319
+ * is the ONE thing a person answers next; `answer()`/`send()`/
308
320
  * `signPrepare()` are the only ways in.
309
321
  * @param {any} card the validated job card (see {@link validateJobCard})
310
322
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
@@ -322,7 +334,7 @@ export function createSession(card, deps = {}) {
322
334
  const timeoutMs = deps.timeoutMs ?? 300_000;
323
335
  // TEST SEAMS ONLY (never set by `src/panel/authorroutes.js`'s real caller):
324
336
  // override the declaration composer and/or `prepareSigning` itself so a
325
- // test can drive the REAL confirm turn / ask() channel / revise-round /
337
+ // test can drive the REAL confirm turn / ask() channel /
326
338
  // hash-matching machinery this file owns, without also re-running (and
327
339
  // re-proving) `authorClose`'s own composer ladder or `prepareSigning`'s own
328
340
  // gates — both already have their own test suites. Absent, both default to
@@ -342,7 +354,6 @@ export function createSession(card, deps = {}) {
342
354
  messages: /** @type {{role: string, text: string}[]} */ ([]),
343
355
  pendingAsk: /** @type {any} */ (null),
344
356
  cost: /** @type {any} */ (null),
345
- revisesLeft: CONFIRM_ROUND_CAP,
346
357
  specHash: /** @type {string|null} */ (null),
347
358
  resolvedSpecPath: /** @type {string|null} */ (null),
348
359
  error: /** @type {string|null} */ (null),
@@ -436,15 +447,9 @@ export function createSession(card, deps = {}) {
436
447
  resolvePending = resolveFn;
437
448
  if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
438
449
  else if (step.kind === 'menu') {
439
- const p = step.plan ?? {};
440
- const lines = [`Plan: ${JSON.stringify(p.goal ?? '')}`, `Checks: ${(p.checks ?? []).join(' · ') || '(none)'}`];
441
- if ((p.notChecked ?? []).length) lines.push(`Not checked: ${p.notChecked.join(' · ')}`);
442
- if ((p.questions ?? []).length) lines.push(`Questions: ${p.questions.join(' · ')}`);
443
- lines.push('Sign & run to confirm this plan, or Revise to describe a change.');
444
- say('bot', lines.join('\n'));
450
+ say('bot', planText(step.plan ?? {}));
445
451
  } else if (step.kind === 'answer') say('bot', `A question the plan raised (${step.index} of ${step.total}): ${step.question}`);
446
452
  else if (step.kind === 'goal') say('bot', 'Type the goal sentence yourself.');
447
- else if (step.kind === 'fix') say('bot', 'What should change?');
448
453
  });
449
454
 
450
455
  /** @param {string|null} value @returns {boolean} */
@@ -457,21 +462,7 @@ export function createSession(card, deps = {}) {
457
462
  return true;
458
463
  };
459
464
 
460
- /** @param {'menu'|'fix'|'answer'|'language'|'goal'} kind @param {number} timeoutMsInner */
461
- const waitForPendingKind = async (kind, timeoutMsInner = 2000) => {
462
- const start = Date.now();
463
- while (Date.now() - start < timeoutMsInner) {
464
- if (state.pendingAsk?.kind === kind) return true;
465
- // eslint-disable-next-line no-await-in-loop
466
- await new Promise((r) => { setTimeout(r, 10); });
467
- }
468
- return state.pendingAsk?.kind === kind;
469
- };
470
-
471
465
  const onPhase = (name, data = {}) => {
472
- if (name === 'confirm-round' && typeof data.round === 'number') {
473
- state.revisesLeft = Math.max(0, CONFIRM_ROUND_CAP - (data.round - 1));
474
- }
475
466
  // COLLAPSED INTO THE PROGRESS INDICATOR ONLY (build item 3) — this used
476
467
  // to also `say('system', `… ${name}`)`, one jargon chat bubble per
477
468
  // library phase (`… confirm-turn-done`, `… seed-read`, …), read back to
@@ -641,10 +632,11 @@ export function createSession(card, deps = {}) {
641
632
  // a detached checkout of the person's current commit, inside their
642
633
  // repo), so a JS/TS repo's tree never carries `node_modules` until
643
634
  // someone installs there. Refusing outright here was ITSELF the F182
644
- // class of bug one layer up. bareloop still never runs an install
645
- // itself — this only re-checks the SAME worktree on demand, via the
646
- // "Check again" button (`checkDeps()`, below), instead of ending the
647
- // session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
635
+ // class of bug one layer up. For an npm lock file bareloop
636
+ // runs `npm ci --ignore-scripts` itself first (P6 item 3, below); any other
637
+ // gap (or a failed install) waits on the person: this re-checks the SAME
638
+ // worktree on demand, via the "Check again" button (`checkDeps()`, below),
639
+ // instead of ending the session.
648
640
  let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
649
641
  // P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
650
642
  // any model call. Any failure (or another lock file) falls through to the wait below, unchanged.
@@ -763,9 +755,28 @@ export function createSession(card, deps = {}) {
763
755
  });
764
756
  const signingFile = join(outDir, 'signing.json');
765
757
  writeFileSync(signingFile, `${JSON.stringify(signing, null, 2)}\n`);
758
+ // The `calibrating` line is the rubric phase the person watched; its ✓ was set long before the gate graded
759
+ // anything. Once the gate has a result, the line says THAT, on its own line (hamr, 2026-10-09).
760
+ const calSum = calibrationSummary(signing.gates?.calibration);
761
+ const calStep = latestStep(state.steps, 'calibrate');
762
+ if (calSum && calStep) {
763
+ calStep.status = calSum.ok ? 'done' : 'failed';
764
+ calStep.detail = calSum.note;
765
+ logSteps();
766
+ }
766
767
 
767
768
  if (!signing.ok) {
768
- refuse(`signing gates failed — ${signing.reds.map((r) => r.code).join(', ') || 'no work red at seed'}`);
769
+ // plain words for the person (src/authorreadout.js, the one owner the CLI prints too); the full reds go into
770
+ // the drafting log on the failed step, so the cause survives the session
771
+ const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
772
+ appendDraftLog(outDir, {
773
+ kind: 'step-reds', no: runningNo === -1 ? state.steps.length - 1 : runningNo, stop: 'signing-gates-failed',
774
+ reds: redsRecord(signing.reds ?? [], { signing: true }), at: new Date().toISOString(),
775
+ });
776
+ refuse(signingStopText({
777
+ reds: signing.reds ?? [], refusal: signing.refusal,
778
+ spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
779
+ }));
769
780
  return;
770
781
  }
771
782
  state.specHash = signing.specHash;
@@ -839,7 +850,7 @@ export function createSession(card, deps = {}) {
839
850
  // TEST SEAMS (see the constructor's own note): a test overrides
840
851
  // `generate`/`confirmGenerate` directly (bypassing this real, constructed
841
852
  // `provider` for the model boundary) and/or `scout` (bypassing the real
842
- // paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
853
+ // paid scout) so it can drive the REAL confirm turn/ask()/hash
843
854
  // machinery below with a deterministic fake, never a live provider call.
844
855
  // no NEW model call starts after an abandon; a call already in flight books its usage via onCall and is discarded
845
856
  const noCallAfterAbandon = (fn) => (...a) => { if (abandoned) throw new Error('abandoned — no new model call'); return fn(...a); };
@@ -864,6 +875,23 @@ export function createSession(card, deps = {}) {
864
875
  ...(authorFnOverride ? { authorFn: authorFnOverride } : {}),
865
876
  });
866
877
 
878
+ if (!authored.ok && PLAIN_PROPOSAL_STOPS.includes(String(authored.stop))) {
879
+ // a refused rubric proposal: the person gets the reason in plain words (src/authorreadout.js, the one owner the
880
+ // CLI prints too), and the full reds go into the drafting log on the failed step so the cause survives the session
881
+ const cases = authored.judged?.signed?.cases ?? authored.judged?.proposal?.proposal?.cases ?? null;
882
+ const reds = authored.reds ?? [];
883
+ const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
884
+ appendDraftLog(outDir, {
885
+ kind: 'step-reds', no: runningNo === -1 ? state.steps.length - 1 : runningNo, stop: authored.stop,
886
+ reds: redsRecord(reds, { cases }), at: new Date().toISOString(),
887
+ });
888
+ refuse(proposalStopText({
889
+ stop: String(authored.stop), reds, cases,
890
+ spend: { knownUsd: state.draftSpentUsd, spendComplete: state.draftSpendComplete },
891
+ source: authored.judged?.source === 'signer' ? 'signer' : 'proposal',
892
+ }));
893
+ return;
894
+ }
867
895
  if (!authored.ok) {
868
896
  refuse(`Stopped: ${authored.stop ?? 'authoring-failed'}${authored.refusal ? ` — ${authored.refusal.detail}` : ''}`, authored.stop === 'confirm-abandoned' ? 'abandoned' : 'refused');
869
897
  return;
@@ -887,7 +915,7 @@ export function createSession(card, deps = {}) {
887
915
  /** @param {string} text */
888
916
  send: (text) => {
889
917
  if (!state.pendingAsk) return { ok: false, error: 'nothing is being asked right now' };
890
- if (state.pendingAsk.kind === 'menu') return { ok: false, error: 'use Sign & run or Revise for a plan, never Send — the chat can never sign or pick a plan action' };
918
+ if (state.pendingAsk.kind === 'menu') return { ok: false, error: 'use Sign & run for a plan, never Send — the chat can never sign or pick a plan action' };
891
919
  if (state.pendingAsk.kind === 'install-needed') return { ok: false, error: 'install the packages, then click Check again — nothing to send here' };
892
920
  say('you', text);
893
921
  answer(text);
@@ -916,17 +944,6 @@ export function createSession(card, deps = {}) {
916
944
  r();
917
945
  return { ok: true };
918
946
  },
919
- /** @param {string} text */
920
- revise: async (text) => {
921
- if (!state.pendingAsk || state.pendingAsk.kind !== 'menu') return { ok: false, error: 'no plan is waiting for a Revise right now' };
922
- if (state.revisesLeft <= 0) return { ok: false, error: 'no revises left (D3: max 2 rounds)' };
923
- answer('fix');
924
- const reached = await waitForPendingKind('fix', 3000);
925
- if (!reached) return { ok: false, error: 'the confirm turn did not ask for a fix in time' };
926
- say('you', text);
927
- answer(text);
928
- return { ok: true };
929
- },
930
947
  /** click 1 — the confirm pick. Never signs on its own. */
931
948
  signPrepare: () => {
932
949
  if (!state.pendingAsk || state.pendingAsk.kind !== 'menu') return { ok: false, error: 'no plan is waiting for a decision right now' };