bareloop 0.41.0 → 0.42.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +44 -4
  4. package/package.json +4 -3
  5. package/src/authorflow.js +337 -29
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +78 -16
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +14 -5
  10. package/src/authorscout.js +108 -0
  11. package/src/declaredclose.js +23 -12
  12. package/src/facts.js +317 -0
  13. package/src/factwords.js +129 -0
  14. package/src/inputlook.js +301 -0
  15. package/src/job.js +2 -1
  16. package/src/jobblock.js +7 -2
  17. package/src/kinds.js +84 -3
  18. package/src/labelscan.js +170 -0
  19. package/src/pagefetch.js +125 -0
  20. package/src/panel/authorroutes.js +15 -2
  21. package/src/panel/authorsession.js +99 -22
  22. package/src/panel/index.html +33 -7
  23. package/src/panel/jobplan.js +34 -11
  24. package/src/panel/server.js +19 -3
  25. package/src/planrun.js +3 -2
  26. package/src/rubricstep.js +804 -0
  27. package/src/source.js +166 -1
  28. package/src/u-readout.js +1 -1
  29. package/src/userrun.js +14 -2
  30. package/types/authorflow.d.ts +267 -13
  31. package/types/authoring.d.ts +29 -1
  32. package/types/authorjob.d.ts +7 -5
  33. package/types/authorreadout.d.ts +27 -2
  34. package/types/authorscout.d.ts +70 -0
  35. package/types/declaredclose.d.ts +4 -2
  36. package/types/facts.d.ts +71 -0
  37. package/types/factwords.d.ts +42 -0
  38. package/types/inputlook.d.ts +238 -0
  39. package/types/jobblock.d.ts +8 -1
  40. package/types/labelscan.d.ts +38 -0
  41. package/types/pagefetch.d.ts +71 -0
  42. package/types/panel/authorsession.d.ts +18 -2
  43. package/types/panel/jobplan.d.ts +12 -4
  44. package/types/panel/server.d.ts +4 -2
  45. package/types/rubricstep.d.ts +286 -0
  46. package/types/source.d.ts +94 -0
  47. package/types/userrun.d.ts +11 -0
@@ -113,8 +113,9 @@
113
113
  .topbar > div:first-child{display:flex;align-items:baseline;gap:0;white-space:nowrap;overflow:hidden;}
114
114
  .topbar .brand{font-weight:700;font-size:17px;letter-spacing:0.06em;flex:none;}
115
115
  .topbar .brand::before{content:"■ ";}
116
- .topbar .tagline{font-size:12px;color:var(--text-dim);margin-left:8px;overflow:hidden;text-overflow:ellipsis;}
117
- .topbar .tagline::before{content:"· ";color:var(--text-faint);}
116
+ .topbar .tagline,.topbar .version{font-size:12px;color:var(--text-dim);margin-left:8px;overflow:hidden;text-overflow:ellipsis;font-weight:400;}
117
+ .topbar .tagline::before,.topbar .version::before{content:"· ";color:var(--text-faint);}
118
+ .topbar .version{flex:none;}
118
119
  @media (max-width: 600px){ .topbar .tagline{display:none;} }
119
120
  .topbar-right{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
120
121
 
@@ -590,6 +591,9 @@
590
591
  .pv-typed input{flex:1 1 160px;min-width:0;max-width:260px;min-height:28px;box-sizing:border-box;}
591
592
  .pv-typed .hint{flex:1 1 100%;margin:0;}
592
593
  .hint.bad{color:var(--red);}
594
+ /* a refused Start (a $0 refusal, e.g. an Inputs line) is read like the red failure line of the progress list, not like a faint hint */
595
+ .card-refusal:not(:empty){font-size:13px;margin-top:8px;line-height:1.4;}
596
+ .card-refusal:empty{display:none;}
593
597
  .field label.jf-thinking{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;gap:6px;margin-top:6px;font-size:12px;text-transform:none;letter-spacing:0;}
594
598
  .field label.jf-thinking input{width:auto;min-height:0;margin:0;}
595
599
  .table-wrap{overflow-x:auto;border:1px solid var(--border-strong);background:var(--panel2);max-width:100%;}
@@ -638,6 +642,7 @@
638
642
  <header class="topbar">
639
643
  <div>
640
644
  <span class="brand">bareloop</span>
645
+ <span class="version" id="panel-version">v__BARELOOP_PANEL_VERSION__</span>
641
646
  <span class="tagline">automate a job, verified</span>
642
647
  </div>
643
648
  <div class="topbar-right">
@@ -726,7 +731,7 @@
726
731
  <input id="jf-cap-time" type="text" placeholder="or blank = no wall">
727
732
  </div>
728
733
  </div>
729
- <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
734
+ <div class="hint bad card-refusal" id="chat-card-error" data-testid="chat-card-error" role="alert"></div>
730
735
  <div id="chat-resume-box" class="chat-resume-box" data-testid="resume-box" hidden>
731
736
  <div class="hint" id="chat-resume-spent" data-testid="resume-spent"></div>
732
737
  <div class="ended-actions"><button class="btn primary" type="button" id="chat-resume-sign" data-testid="btn-sign-resume">Sign &amp; resume</button> <button class="btn" type="button" id="chat-resume-cancel" data-testid="btn-resume-cancel">Cancel</button></div>
@@ -2488,6 +2493,19 @@
2488
2493
  }, 1500);
2489
2494
  }
2490
2495
 
2496
+ // M3 (Part 1c full-run fixes): after Sign & run the new run's row is written a few seconds later (the runner starts, then
2497
+ // appends its row), and the Runs list only re-fetches every RUNS_LIST_POLL_MS — so a run just signed showed only after a page
2498
+ // refresh. Like a resume, a sign forces list refreshes until a run that was not listed before the click appears; bounded.
2499
+ function afterSignRefresh(knownIds, tries){
2500
+ refreshRunsList(true).then(function(runs){
2501
+ var fresh = Array.isArray(runs) && runs.some(function(r){ return knownIds.indexOf(r.runid) === -1; });
2502
+ if(!fresh && tries > 0) setTimeout(function(){ afterSignRefresh(knownIds, tries - 1); }, 1500);
2503
+ });
2504
+ }
2505
+ function currentRunIds(){
2506
+ try { return (JSON.parse(lastRunsSig || "[]") || []).map(function(r){ return r.runid; }); } catch(e){ return []; }
2507
+ }
2508
+
2491
2509
  function renderRun(detail){
2492
2510
  if(detail.fileMissing){
2493
2511
  setRunHeader("\u00B7", runName(detail.job, detail.runNo, detail.runid), "file missing");
@@ -3235,14 +3253,15 @@
3235
3253
  // (src/panel/jobplan.js): numbered lines with their rule, the checks under the line each serves (folded), the AI's own
3236
3254
  // "Not checked", the always-on guards. Only the marker is blue (--plan). Returns HTML (every text is escaped).
3237
3255
  function renderJobPlan(plan){
3238
- var h = '<div class="jp" data-testid="job-plan">' + (plan.older ? '<div class="older" data-testid="plan-older">older job \u2014 numbered lines were not saved</div>' : "") + '<div class="jp-legend"><b>\u203A</b> = the approved plan</div>' +
3256
+ var h = '<div class="jp" data-testid="job-plan">' + (plan.older ? '<div class="older" data-testid="plan-older">older job \u2014 numbered lines were not saved</div>' : "") + '<div class="jp-legend">Done when every <b>\u203A</b> check passes</div>' +
3239
3257
  (plan.goal && !plan.older ? '<div class="jp-sum" data-testid="plan-goal"><span class="lbl">AI\'s summary</span>' + escapeXml(plan.goal) + "</div>" : "");
3240
3258
  var checkLine = function(c){
3241
3259
  return '<div class="fold" data-testid="plan-check"><span class="mk">\u203A</span> check \u00B7 ' + escapeXml(c.name) + (c.cls ? " \u00B7 " + escapeXml(c.cls) : "") + "</div>";
3242
3260
  };
3243
3261
  (plan.lines || []).forEach(function(l){
3244
3262
  h += '<div class="jl" data-testid="plan-line"><span class="num">' + escapeXml(String(l.n)) + "</span>" + escapeXml(l.text);
3245
- if(l.rule) h += '<div class="sub">~ ' + escapeXml(l.rule) + "</div>";
3263
+ // one `~` row per rule (fwdloop's shape); an older plan that carries only the joined `rule` still shows it
3264
+ (l.rules && l.rules.length ? l.rules : (l.rule ? [l.rule] : [])).forEach(function(r){ h += '<div class="sub">~ ' + escapeXml(r) + "</div>"; });
3246
3265
  (l.checks || []).forEach(function(c){ h += checkLine(c); });
3247
3266
  (l.refused || []).forEach(function(r){ h += '<div class="fold" data-testid="plan-refused"><span class="mk">\u203A</span> not checked \u00B7 ' + escapeXml(r) + "</div>"; });
3248
3267
  h += "</div>";
@@ -3256,7 +3275,7 @@
3256
3275
  items.map(function(x){ return "<li>" + escapeXml(x) + "</li>"; }).join("") + "</ul></details>";
3257
3276
  };
3258
3277
  h += fold("Not checked (the AI's own reading)", plan.notChecked, "plan-notchecked");
3259
- h += fold("Always on", plan.alwaysOn, "plan-alwayson");
3278
+ h += fold("Always checked", plan.alwaysOn, "plan-alwayson");
3260
3279
  return h + "</div>";
3261
3280
  }
3262
3281
 
@@ -3402,7 +3421,7 @@
3402
3421
  pollFailStreak = 0;
3403
3422
  var runs = result.runs || [];
3404
3423
  var next = sig(runs);
3405
- if(next === lastRunsSig) return; // unchanged payload — never rebuild the DOM for nothing
3424
+ if(next === lastRunsSig) return runs; // unchanged payload — never rebuild the DOM for nothing
3406
3425
  lastRunsSig = next;
3407
3426
  withScrollPreserved(["left-pane-body"], function(){
3408
3427
  // setItems re-runs the SAME filter/search state and view mode
@@ -3410,6 +3429,7 @@
3410
3429
  // bar's own state) — a poll tick never resets either.
3411
3430
  runsFilterBar.setItems(runs);
3412
3431
  });
3432
+ return runs;
3413
3433
  }).catch(function(){
3414
3434
  pollFailStreak++;
3415
3435
  // Keep the last good render; a transient failure is invisible. Only a
@@ -3906,6 +3926,7 @@
3906
3926
  var mainHint = document.getElementById("chat-main-hint");
3907
3927
  var reopenBtn = document.getElementById("chat-reopen-btn");
3908
3928
  var mainAction = "none";
3929
+ var knownRunIdsAtSign = null; // the run ids listed when Sign & run was pressed (M3: afterSignRefresh waits for a new one)
3909
3930
  var lastState = null;
3910
3931
  var startOk = false;
3911
3932
  var errEl = document.getElementById("chat-card-error");
@@ -4596,6 +4617,7 @@
4596
4617
  renderActions(j.state);
4597
4618
  if(reuseSession && !autoSigned && j.state.phase === "prepared" && j.state.specHash){
4598
4619
  autoSigned = true;
4620
+ knownRunIdsAtSign = currentRunIds();
4599
4621
  authorPost("/api/author/" + sessionId + "/sign", {specHash: j.state.specHash}).then(function(r){
4600
4622
  var o = chatPostOutcome(r);
4601
4623
  if(o.ok){ chatActionOk(); } else { autoSigned = false; chatActionFailed(o.error); }
@@ -4604,6 +4626,7 @@
4604
4626
  }
4605
4627
  if(j.state.phase === "signed"){
4606
4628
  clearInterval(pollTimer);
4629
+ afterSignRefresh(knownRunIdsAtSign || currentRunIds(), 12);
4607
4630
  document.getElementById("tab-runs").click();
4608
4631
  // hamr's ruling 2026-10-05: the run has started, so the Chat card goes back to the empty card (the one reset
4609
4632
  // Clear uses) — it must not keep showing a signed job that invites a rerun. The run itself is untouched.
@@ -4626,6 +4649,8 @@
4626
4649
  authorPost("/api/author/start", body).then(function(r){
4627
4650
  if(r.status !== 200 || !r.body.ok){
4628
4651
  errEl.textContent = (r.body && r.body.error) || "refused";
4652
+ // the refusal is the answer to the click: bring it into view (it sits under the cap fields, above the progress list)
4653
+ try { errEl.scrollIntoView({block: "nearest"}); } catch(e){}
4629
4654
  sessionLive = false;
4630
4655
  refreshStartEnabled();
4631
4656
  return;
@@ -4906,6 +4931,7 @@
4906
4931
  authorGet("/api/author/" + sessionId).then(function(j){
4907
4932
  var hash = j.state && j.state.specHash;
4908
4933
  if(!hash) return;
4934
+ knownRunIdsAtSign = currentRunIds();
4909
4935
  authorPost("/api/author/" + sessionId + "/sign", {specHash: hash}).then(function(r){
4910
4936
  var o = chatPostOutcome(r);
4911
4937
  if(o.ok){ chatActionOk(); } else { chatActionFailed(o.error); return; }
@@ -2,7 +2,7 @@
2
2
  // `renderJobPlan()` draws in two places — the drafting confirm card (the plan bubble) and the read-only Job tab. The
3
3
  // server builds it; the page only draws it (so the two places can never read the same plan two ways).
4
4
  //
5
- // { lines: [{n, text, rule, checks: [{name, cls}], refused: [reason...]}],
5
+ // { lines: [{n, text, rule, rules: [string...], checks: [{name, cls}], refused: [reason...]}],
6
6
  // loose: [{name, cls}], checks that name no job line (an older caller, or a model slip)
7
7
  // notChecked: [text...], "the AI's own reading" — what the drafter says nothing checks
8
8
  // alwaysOn: [text...], the mandatory guards / protections, which serve no job line
@@ -11,9 +11,10 @@
11
11
  // `cls` is 'machine check' | 'judge' | null (null = not known yet: the confirm card's checks are the model's plan, the
12
12
  // stage kinds exist only after drafting). Pure; no file IO.
13
13
  import { KIND_CATALOGUE } from '../authoring.js';
14
+ import { checksOfClose, proseNotChecked, lineRules } from '../rubricstep.js';
14
15
 
15
16
  /** @typedef {{name: string, cls: 'machine check'|'judge'|null}} PlanCheck */
16
- /** @typedef {{n: number, text: string, rule: string, checks: PlanCheck[], refused: string[]}} PlanLine */
17
+ /** @typedef {{n: number, text: string, rule: string, rules: string[], checks: PlanCheck[], refused: string[]}} PlanLine */
17
18
  /** @typedef {{lines: PlanLine[], loose: PlanCheck[], notChecked: string[], alwaysOn: string[], older: boolean, goal?: string}} JobPlan */
18
19
 
19
20
  /**
@@ -29,9 +30,19 @@ export function checkClassOfKind(kind) {
29
30
  return spec.verdictClass === 'green' ? 'machine check' : 'judge';
30
31
  }
31
32
 
33
+ /**
34
+ * The `~` rules of one job line, each on its own (the page draws one `~` row per rule): the line's own `rules` array when it
35
+ * has one (a rule may itself contain "; "), else the joined `rule` split on "; " (an older line that saved only the join).
36
+ * @param {any} l @returns {string[]}
37
+ */
38
+ export function rulesOfLine(l) {
39
+ if (Array.isArray(l?.rules)) return l.rules.filter((/** @type {unknown} */ r) => typeof r === 'string' && r);
40
+ return typeof l?.rule === 'string' && l.rule ? l.rule.split('; ').filter(Boolean) : [];
41
+ }
42
+
32
43
  /**
33
44
  * The plan of a SIGNED spec. With `jobLines`, each stage lands under EACH line its `fromLine` array names (a stage with none is
34
- * a mandatory guard: "Always on"), `closeDecl.refused` reasons land under their line, and `closeDecl.notes` are the
45
+ * a mandatory guard: "Always checked"), `closeDecl.refused` reasons land under their line, and `closeDecl.notes` are the
35
46
  * drafter's own "not checked" words. Without `jobLines` (a spec that predates the job block) the signed goal sentence is
36
47
  * line 1 and every stage name is a check under it — `older: true`.
37
48
  * @param {any} spec
@@ -42,24 +53,34 @@ export function planFromSpec(spec) {
42
53
  let stages = [];
43
54
  if (spec && Array.isArray(spec.closeDecl?.stages)) {
44
55
  stages = spec.closeDecl.stages.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && s.name)
45
- .map((/** @type {any} */ s) => ({ name: s.name, cls: checkClassOfKind(s.kind), fromLine: Array.isArray(s.fromLine) && s.fromLine.length ? s.fromLine.filter(Number.isInteger) : null }));
56
+ .flatMap((/** @type {any} */ s) => {
57
+ const fromLine = Array.isArray(s.fromLine) && s.fromLine.length ? s.fromLine.filter(Number.isInteger) : null;
58
+ // PART 1b: a rubric-step stage is shown as the checks it RUNS — one code-worded row per fact, per count, per limit — read by
59
+ // the same reader (`checksOfClose`) the confirm card and the sign-time invariant use, so the Job tab cannot differ from them
60
+ const rows = s.kind === 'rubric-step' ? checksOfClose({ stages: [s] }) : [];
61
+ return rows.length > 0 ? rows.map((r) => ({ name: r.text, cls: /** @type {'machine check'} */ ('machine check'), fromLine })) : [{ name: s.name, cls: checkClassOfKind(s.kind), fromLine }];
62
+ });
46
63
  } else if (spec && Array.isArray(spec.close)) {
47
64
  stages = spec.close.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && s.name)
48
65
  .map((/** @type {any} */ s) => ({ name: s.name, cls: /** @type {'machine check'} */ ('machine check'), fromLine: null }));
49
66
  }
50
- const notes = Array.isArray(spec?.closeDecl?.notes) ? spec.closeDecl.notes.filter((/** @type {any} */ n) => typeof n === 'string' && n) : [];
67
+ // a prose step says what it does not check (PART 1b, RF5): the person judges the text at Accept
68
+ const notes = [
69
+ ...(Array.isArray(spec?.closeDecl?.notes) ? spec.closeDecl.notes.filter((/** @type {any} */ n) => typeof n === 'string' && n) : []),
70
+ ...proseNotChecked(spec?.closeDecl),
71
+ ];
51
72
  const jl = Array.isArray(spec?.jobLines) ? spec.jobLines.filter((/** @type {any} */ l) => l && Number.isInteger(l.n) && typeof l.text === 'string') : [];
52
73
  if (jl.length === 0) {
53
74
  return {
54
75
  lines: [{
55
- n: 1, text: typeof spec?.goal === 'string' && spec.goal ? spec.goal : '(no goal recorded)', rule: '',
76
+ n: 1, text: typeof spec?.goal === 'string' && spec.goal ? spec.goal : '(no goal recorded)', rule: '', rules: [],
56
77
  checks: stages.map((s) => ({ name: s.name, cls: s.cls })), refused: [],
57
78
  }],
58
79
  loose: [], notChecked: notes, alwaysOn: [], older: true,
59
80
  };
60
81
  }
61
82
  /** @type {PlanLine[]} */
62
- const lines = jl.map((/** @type {any} */ l) => ({ n: l.n, text: l.text, rule: typeof l.rule === 'string' ? l.rule : '', checks: [], refused: [] }));
83
+ const lines = jl.map((/** @type {any} */ l) => ({ n: l.n, text: l.text, rule: typeof l.rule === 'string' ? l.rule : '', rules: rulesOfLine(l), checks: [], refused: [] }));
63
84
  /** @type {PlanCheck[]} */
64
85
  const loose = [];
65
86
  /** @type {string[]} */
@@ -74,7 +95,9 @@ export function planFromSpec(spec) {
74
95
  const refused = Array.isArray(spec?.closeDecl?.refused) ? spec.closeDecl.refused : [];
75
96
  for (const r of refused) {
76
97
  const line = lines.find((l) => l.n === r?.line);
77
- if (line && typeof r.reason === 'string') line.refused.push(r.reason);
98
+ // a refusal of ONE ~ rule (PART 1b, RF16) names the rule in the person's words, so the row reads as that rule's Not checked
99
+ const ruleText = line && Number.isInteger(r.rule) ? lineRules(spec.jobLines.find((/** @type {any} */ l) => l?.n === r.line))[r.rule - 1] : null;
100
+ if (line && typeof r.reason === 'string') line.refused.push(ruleText ? `"${ruleText}" — ${r.reason}` : r.reason);
78
101
  }
79
102
  // the model's signed goal sentence rides along: the page draws it as the "AI's summary" row (an older job shows it as line 1 instead)
80
103
  return { lines, loose, notChecked: notes, alwaysOn, older: false, ...(typeof spec?.goal === 'string' && spec.goal ? { goal: spec.goal } : {}) };
@@ -82,8 +105,8 @@ export function planFromSpec(spec) {
82
105
 
83
106
  /**
84
107
  * The plan of the confirm turn, before anything is drafted: the person's own job lines, the model's checks under the
85
- * line each names, its own "not checked" words, and the real protections (code-owned) as "Always on".
86
- * @param {{n: number, text: string, rule: string}[]|null|undefined} jobLines
108
+ * line each names, its own "not checked" words, and the real protections (code-owned) as "Always checked".
109
+ * @param {{n: number, text: string, rule: string, rules?: string[]}[]|null|undefined} jobLines
87
110
  * @param {{checkItems?: {text: string, fromLine: number[]|null}[], checks?: string[], notChecked?: string[], protections?: string[]}} plan
88
111
  * @returns {JobPlan}
89
112
  */
@@ -93,7 +116,7 @@ export function planFromConfirm(jobLines, plan) {
93
116
  : (Array.isArray(plan?.checks) ? plan.checks.map((c) => ({ text: String(c), fromLine: null })) : []);
94
117
  const jl = Array.isArray(jobLines) ? jobLines : [];
95
118
  /** @type {PlanLine[]} */
96
- const lines = jl.map((l) => ({ n: l.n, text: l.text, rule: l.rule, checks: [], refused: [] }));
119
+ const lines = jl.map((l) => ({ n: l.n, text: l.text, rule: l.rule, rules: rulesOfLine(l), checks: [], refused: [] }));
97
120
  /** @type {PlanCheck[]} */
98
121
  const loose = [];
99
122
  for (const c of items) {
@@ -60,6 +60,16 @@ import { planFromSpec } from './jobplan.js';
60
60
 
61
61
  const HERE = dirname(fileURLToPath(import.meta.url));
62
62
 
63
+ /** bareloop's own version, from its package.json (read each time the page is served); 'unknown' if it cannot be read, never a guess
64
+ * @param {string} [file] test seam: another package.json
65
+ * @returns {string} */
66
+ function packageVersion(file = join(HERE, '..', '..', 'package.json')) {
67
+ try {
68
+ const v = JSON.parse(readFileSync(file, 'utf8')).version;
69
+ return typeof v === 'string' && v.length > 0 ? v : 'unknown';
70
+ } catch { return 'unknown'; }
71
+ }
72
+
63
73
  /** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
64
74
  const MAX_BODY_BYTES = 1024 * 1024;
65
75
 
@@ -1810,6 +1820,7 @@ function resolveSpecForRow(row) {
1810
1820
  * change any file?"
1811
1821
  * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
1812
1822
  * casts or silencers added?"
1823
+ * - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
1813
1824
  * - `command-exit` is the genre's `suite-green` stage (the only shipped use
1814
1825
  * of this kind): "does the test suite pass?"
1815
1826
  * - `count-not-worse` splits three ways on its own declared
@@ -1877,6 +1888,8 @@ function stageQuestionText({
1877
1888
  if (kind === 'files-changed') return 'did it change any file?';
1878
1889
  if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
1879
1890
  if (kind === 'command-exit') return 'does the test suite pass?';
1891
+ // Part 1a: one rubric-step stage = one job line's output file
1892
+ if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
1880
1893
  if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
1881
1894
  const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
1882
1895
  if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
@@ -2710,7 +2723,7 @@ function sendText(res, code, text) {
2710
2723
  * only `createPanelServer` below always supplies one.
2711
2724
  * @param {import('node:http').IncomingMessage} req
2712
2725
  * @param {import('node:http').ServerResponse} res
2713
- * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
2726
+ * @param {{ home?: string, packageJsonPath?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes>, runRoutes?: ReturnType<typeof createRunRoutes>, importRoutes?: ReturnType<typeof createImportRoutes> }} opts
2714
2727
  */
2715
2728
  export function handleRequest(req, res, opts) {
2716
2729
  /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
@@ -2806,6 +2819,9 @@ export function handleRequest(req, res, opts) {
2806
2819
  return;
2807
2820
  }
2808
2821
  html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
2822
+ // the topbar reads `bareloop · v<version> · ...`: the version is bareloop's OWN package.json, read when the page is served
2823
+ // and templated in exactly like the port — never written into the HTML
2824
+ html = html.replace(/__BARELOOP_PANEL_VERSION__/g, packageVersion(opts.packageJsonPath));
2809
2825
  // PANEL-BUILD.md P3's human-click guard: a per-server-start token,
2810
2826
  // templated into the page exactly like the port — absent on every P1/P2
2811
2827
  // caller (no `opts.token`), which is fine: the page's own JS only reads
@@ -2887,7 +2903,7 @@ export function handleRequest(req, res, opts) {
2887
2903
  * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
2888
2904
  * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
2889
2905
  * fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
2890
- * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
2906
+ * openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string, packageJsonPath?: string }} [opts]
2891
2907
  * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
2892
2908
  */
2893
2909
  export function createPanelServer(opts = {}) {
@@ -2912,7 +2928,7 @@ export function createPanelServer(opts = {}) {
2912
2928
  const server = createServer((req, res) => {
2913
2929
  try {
2914
2930
  handleRequest(req, res, {
2915
- home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes,
2931
+ home, port: boundPort, token, authorRoutes, settingsRoutes, runRoutes, importRoutes, packageJsonPath: opts.packageJsonPath,
2916
2932
  });
2917
2933
  } catch (e) {
2918
2934
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
package/src/planrun.js CHANGED
@@ -45,6 +45,7 @@ import { defaultJudgeLoop } from './judged.js';
45
45
  export { rateSourceFields };
46
46
  import { createClock, isWallTimeout } from './clock.js';
47
47
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
48
+ import { inputLabelsOf } from './rubricstep.js';
48
49
  import {
49
50
  seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
50
51
  HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
@@ -1886,7 +1887,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1886
1887
  },
1887
1888
  decisionReady: true,
1888
1889
  decision: `The close minted ${outcome} and it STANDS. What happens to the work is yours: accept it, rerun it with your own words as the gap, or pause and come back.`
1889
- + (held ? ' This green was rendered by the judged floor, so it has earned no reuse until you accept it.' : ''),
1890
+ + (held ? ' This is a rubric (soft-green) job, so its green earns no reuse until you accept it, whether or not a judge ran.' : ''),
1890
1891
  options: [...HUMAN_DECISIONS],
1891
1892
  meaning: 'not a verdict — the verdict is already minted and nothing an answer does can change it; this records a disposition',
1892
1893
  });
@@ -2036,7 +2037,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2036
2037
  }
2037
2038
  const listed = await seedListing(workdir, s.seedRef);
2038
2039
  const v = listed.stop === null
2039
- ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null })
2040
+ ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null , inputLabels: inputLabelsOf(job.inputs) })
2040
2041
  : { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
2041
2042
  emit('close-decl', {
2042
2043
  genre: job.closeDecl.genre,