bareloop 0.44.0 → 0.46.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 (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/bareloop.context.md +31 -5
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorflow.js +1 -1
  6. package/src/authorjob.js +25 -1
  7. package/src/authorreadout.js +13 -2
  8. package/src/authorrun.js +8 -2
  9. package/src/authorscout.js +1 -1
  10. package/src/bundle.js +90 -6
  11. package/src/close-integrity.js +39 -5
  12. package/src/config.js +5 -2
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +1 -47
  15. package/src/judged.js +3 -3
  16. package/src/labelscan.js +13 -4
  17. package/src/ladder.js +2 -2
  18. package/src/learn.js +943 -0
  19. package/src/learnstore.js +154 -0
  20. package/src/modellist.js +1 -1
  21. package/src/pagedropwords.js +47 -1
  22. package/src/pagedry.js +124 -0
  23. package/src/pagefetch.js +2 -1
  24. package/src/pagemap.js +7 -2
  25. package/src/pagerules.js +35 -13
  26. package/src/panel/authorroutes.js +1 -1
  27. package/src/panel/authorsession.js +37 -6
  28. package/src/panel/index.html +100 -0
  29. package/src/panel/jobplan.js +12 -7
  30. package/src/panel/server.js +8 -125
  31. package/src/planrun.js +40 -6
  32. package/src/promptregisters.js +4 -0
  33. package/src/providerrows.js +3 -3
  34. package/src/rubricstep.js +36 -6
  35. package/src/run.js +4 -2
  36. package/src/schedulewords.js +1 -1
  37. package/src/source.js +1 -1
  38. package/src/stagewords.js +144 -0
  39. package/src/text.js +4 -2
  40. package/src/userrun.js +28 -0
  41. package/src/validate.js +10 -5
  42. package/src/worktree.js +8 -2
  43. package/types/attemptdiff.d.ts +38 -0
  44. package/types/authorjob.d.ts +10 -2
  45. package/types/config.d.ts +1 -1
  46. package/types/fitcheck.d.ts +113 -0
  47. package/types/inputlook.d.ts +0 -23
  48. package/types/labelscan.d.ts +6 -0
  49. package/types/learn.d.ts +575 -0
  50. package/types/learnstore.d.ts +107 -0
  51. package/types/pagedropwords.d.ts +13 -0
  52. package/types/pagedry.d.ts +37 -0
  53. package/types/pagerules.d.ts +7 -1
  54. package/types/panel/authorsession.d.ts +4 -2
  55. package/types/panel/jobplan.d.ts +5 -2
  56. package/types/panel/server.d.ts +1 -0
  57. package/types/planrun.d.ts +13 -3
  58. package/types/reuse.d.ts +1 -1
  59. package/types/rubricstep.d.ts +5 -0
  60. package/types/run.d.ts +8 -2
  61. package/types/stagewords.d.ts +90 -0
@@ -232,7 +232,7 @@ export function startedAuditWords(rec, originNo) {
232
232
 
233
233
  /** a refusal's own words without its trailing "Nothing started." @param {unknown} why */
234
234
  function reasonOf(why) {
235
- return String(why ?? '').replace(/\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
235
+ return String(why ?? '').replace(/(?<!\s)\s*Nothing (started|spent)\.\s*$/, '').trim().replace(/\.$/, '');
236
236
  }
237
237
 
238
238
  /**
package/src/source.js CHANGED
@@ -1273,7 +1273,7 @@ export async function proveRubricInputs({ inputs, into, destination, fetchUrl =
1273
1273
  labels.add(label.toLowerCase());
1274
1274
  if (isWebUrl(value)) {
1275
1275
  const got = await fetchUrl(value.trim());
1276
- if (!got.ok) return bad('input-url-unreadable', n, `${got.error.replace(/[.\s]+$/, '')}.`);
1276
+ if (!got.ok) return bad('input-url-unreadable', n, `${got.error.replace(/(?<![.\s])[.\s]+$/, '')}.`);
1277
1277
  const pageBuf = Buffer.from(got.text, 'utf8');
1278
1278
  const leak = secretPatternNames(got.text);
1279
1279
  if (leak.length) return bad('input-url-secret', n, `the page at ${got.url} carries a known secret shape (${leak.join(', ')}) - refused, secrets never enter a job.`);
@@ -0,0 +1,144 @@
1
+ // The ONE owner of a stage's plain question ("did it change any file?", "no failing tests?"). Moved out of
2
+ // src/panel/server.js (verbatim) so the Audit/step map AND the learnings cards read the same sentence from the same
3
+ // code — a second spelling is how two screens come to disagree about one check. Pure; no file IO, no model.
4
+
5
+ /**
6
+ * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
7
+ * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
8
+ * when 5 never ran"). ONE pure function mapping a declared stage's own
9
+ * signed `kind`+`params` (plus the close's own `genre`, also a signed field)
10
+ * to a short plain-English question a person can read without knowing the
11
+ * catalogue — never a second spelling on the client, and never anything the
12
+ * model authored (the catalogue's kinds/params are the whole vocabulary this
13
+ * reads).
14
+ *
15
+ * Every branch is traced straight off `src/kinds.js`'s runner semantics and
16
+ * the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
17
+ * `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
18
+ * - `files-changed` is always the `changed-from-seed` guard: "did it
19
+ * change any file?"
20
+ * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
21
+ * casts or silencers added?"
22
+ * - `rubric-step` (Part 1a) is one job line's output file: "line N — does its output pass the line's checks?"
23
+ * - `command-exit` is the genre's `suite-green` stage (the only shipped use
24
+ * of this kind): "does the test suite pass?"
25
+ * - `count-not-worse` splits three ways on its own declared
26
+ * `direction`/`baseline` (never the runtime number — see
27
+ * {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
28
+ * travel separately from the measured figure):
29
+ * - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
30
+ * floor: "did all the old tests still exist?"
31
+ * - `lower-is-better` + `baseline: 0` is the genre's in-scope
32
+ * `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
33
+ * command a `command-exit` stage in the same close already runs
34
+ * (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
35
+ * suite exits clean AND reports zero failing tests — TWO assertions"
36
+ * over ONE population (the same `npm test`/`pytest` invocation), so a
37
+ * `count-not-worse` stage sharing that exact command is the failing-
38
+ * test-count half of that pair, never a typecheck stage: "no failing
39
+ * tests?" — derived from the same cmd+args correlation (the stage
40
+ * counts the suite's own failing tests), never from the stage's own
41
+ * name string. The live check that found this (run mul5fofw's own
42
+ * `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
43
+ * `suite-green` command-exit stage) would otherwise have read "test/
44
+ * has 0 type errors?", which is false: its parser counts FAILING
45
+ * TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
46
+ * name this half honestly rather than reusing "type errors" for a
47
+ * population it was never about, or falling back to the stage's raw
48
+ * name. When the command differs from every sibling `command-exit`
49
+ * stage, the declared `scope.includePrefixes` naming exactly ONE path
50
+ * is quoted verbatim (a signed param, never invented): "<path> has 0
51
+ * type errors?" — otherwise the generic "type errors is 0?", never a
52
+ * guessed filename.
53
+ * - `lower-is-better` + `baseline: 'seed'` with a non-empty
54
+ * `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
55
+ * "other files didn't get more type errors?"
56
+ * The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
57
+ * the only genre this catalogue admits today (doc-genre kinds are not yet
58
+ * built) — an unrecognised genre, or a `count-not-worse` shape none of the
59
+ * three above matches, returns `null` rather than invent a fourth shape.
60
+ *
61
+ * `null` means "no honest plain question" — the caller (buildAttemptsList,
62
+ * partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
63
+ * exactly as it already does for a stage with no resolvable kind at all.
64
+ * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
65
+ * @returns {string|null}
66
+ */
67
+ export const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
68
+
69
+ /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
70
+ * itself) that two stages measure the SAME command's output, used to tell a
71
+ * genuine typecheck stage apart from the TYPES genre's other `lower-is-
72
+ * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
73
+ * "two assertions"), which shares that stage's own shape but not its
74
+ * population. `null` for a stage whose `cmd` isn't a non-empty string —
75
+ * never matches anything, so it never falsely suppresses a real typecheck
76
+ * question.
77
+ * @param {any} params @returns {string|null} */
78
+ export function cmdKey(params) {
79
+ if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
80
+ const args = Array.isArray(params.args) ? params.args : [];
81
+ return JSON.stringify([params.cmd, args]);
82
+ }
83
+
84
+ export function stageQuestionText({
85
+ kind, params, genre, suiteCmdKeys,
86
+ }) {
87
+ if (kind === 'files-changed') return 'did it change any file?';
88
+ if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
89
+ if (kind === 'command-exit') return 'does the test suite pass?';
90
+ // Part 1a: one rubric-step stage = one job line's output file
91
+ if (kind === 'rubric-step') return params && Number.isInteger(params.step) ? `line ${params.step} — does its output pass the line's checks?` : null;
92
+ if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
93
+ const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
94
+ if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
95
+ return 'did all the old tests still exist?';
96
+ }
97
+ if (!what) return null;
98
+ const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
99
+ if (params.direction === 'lower-is-better' && params.baseline === 0) {
100
+ const key = cmdKey(params);
101
+ if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
102
+ const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
103
+ if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
104
+ return `${include[0]} has 0 ${what}?`;
105
+ }
106
+ return `${what} is 0?`;
107
+ }
108
+ if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
109
+ const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
110
+ if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
111
+ return null;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Stage name to its plain question, off a signed spec's `closeDecl.stages` — the one pass that gathers every
118
+ * `command-exit` stage's cmd+args FIRST (so the failing-test-count half of `suite-green`'s pair can be told from a
119
+ * genuine typecheck stage) and then asks {@link stageQuestionText} per stage. `null` for a stage with no honest
120
+ * question; an empty map for a spec with no declared stages.
121
+ * @param {any} spec
122
+ * @returns {Map<string, string|null>}
123
+ */
124
+ export function stageQuestionsByName(spec) {
125
+ /** @type {Map<string, string|null>} */
126
+ const out = new Map();
127
+ if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return out;
128
+ const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
129
+ /** @type {Set<string>} */
130
+ const suiteCmdKeys = new Set();
131
+ for (const s of spec.closeDecl.stages) {
132
+ if (s && s.kind === 'command-exit') {
133
+ const key = cmdKey(s.params);
134
+ if (key !== null) suiteCmdKeys.add(key);
135
+ }
136
+ }
137
+ for (const s of spec.closeDecl.stages) {
138
+ if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
139
+ const kind = typeof s.kind === 'string' ? s.kind : null;
140
+ const params = s.params && typeof s.params === 'object' ? s.params : null;
141
+ out.set(s.name, stageQuestionText({ kind, params, genre, suiteCmdKeys }));
142
+ }
143
+ return out;
144
+ }
package/src/text.js CHANGED
@@ -42,10 +42,12 @@ const FENCE_PREAMBLE_LINES = 5;
42
42
  */
43
43
  export function extractArtifact(t) {
44
44
  const text = String(t ?? '');
45
- const m = text.match(/```[^\n`]*\n?([\s\S]*?)```/);
45
+ // (?=([^\n`]*))\1 reads the info string atomically: giving characters back can never find a closing fence the full read missed (a fence starts with a
46
+ // backtick, which the info string excludes), so an unclosed "```aaaa..." fails once instead of once per giveback (quadratic). The body is group 2.
47
+ const m = text.match(/```(?=([^\n`]*))\1\n?([\s\S]*?)```/);
46
48
  if (m) {
47
49
  if (text.slice(0, /** @type {number} */ (m.index)).split('\n').length - 1 < FENCE_PREAMBLE_LINES) {
48
- const code = m[1].trim();
50
+ const code = m[2].trim();
49
51
  return code ? { code, red: null } : { code: null, red: 'empty fenced block' };
50
52
  }
51
53
  // deep fence = the artifact's own content: verbatim, NO fence strips — the
package/src/userrun.js CHANGED
@@ -82,6 +82,7 @@ import { keysForDoor } from './keysfile.js';
82
82
  import { parseJsonl } from './replayio.js';
83
83
  import { claimRun, monthlyRefusalText, legSpend, chainSpend } from './monthly.js';
84
84
  import { ConfigError } from './config.js';
85
+ import { learnAtRunEnd, hintsForRun, releaseRun } from './learnstore.js';
85
86
  import { commandFor } from './invoke.js';
86
87
  import { judgeRatesFor, keyNameFor, keyValueOf, priceReadout, ratesFor, ratesOfLine, resolveWorker, judgeRowFor, rowsForHome, REFUSE_NAME_MODEL } from './providerrows.js';
87
88
  // the banner's wall arithmetic, extracted so it is reachable by a test (F83): the
@@ -1306,6 +1307,11 @@ async function execute(ctx) {
1306
1307
  if (RULING.decision !== 'rerun') {
1307
1308
  out(`\n${RULING.decision === 'accept' ? 'ACCEPTED' : 'PAUSED'} — the verdict this run minted (${doorRecord.outcome}) is UNTOUCHED; what you answered is what happens to the work.`);
1308
1309
  if (RULING.decision === 'accept') {
1310
+ // Cross-run learnings: the human Accept is what releases a held rubric run's lessons so they count (forward only; a rerun or a
1311
+ // pause never does). Its own try/catch: a record, never a verdict.
1312
+ if (cfgOn && DOOR !== null) {
1313
+ try { releaseRun({ home: cfgHome, runid: DOOR.includes('/') || DOOR.endsWith('.jsonl') ? basename(DOOR).replace(/^u-/, '').replace(/\.jsonl$/, '') : DOOR }); } catch (/** @type {any} */ e) { err(`WARNING: could not release this run's lessons in the learnings ledger (${redactSecrets(String(e?.message ?? e)).split('\n')[0]}).`); }
1314
+ }
1309
1315
  out(` proved ${ans.mechanical?.ran ?? 0} mechanical stage(s) re-ran on the tree as it stands, and passed — an accept is not a rubber stamp`);
1310
1316
  out(` credit ${ans.released ? 'RELEASED — this workflow is now eligible for reuse' : ((ctx.registry ?? null) === null ? 'not released: no --registry was named, so there is no entry to release (the answer is on the spine)' : (quarantinesCredit(spec.verdictType) ? 'nothing left to release — this row was already released by an earlier accept (a repeat answer records nothing new)' : 'nothing was held to release — a green-class row is minted born RELEASED (hamr, 2026-08-30): a machine proved it, so there was never a credit to quarantine'))}`);
1311
1317
  if (ans.note) out(` note ${ans.note}`);
@@ -1895,10 +1901,18 @@ async function execute(ctx) {
1895
1901
  // honours it, and the `finally` below clears whatever is left when the leg ends.
1896
1902
  const stopFile = stopFilePath(spineFile);
1897
1903
  try { rmSync(stopFile, { force: true }); } catch { /* best-effort — a stop surface must never kill the run */ }
1904
+ // Cross-run learnings (src/learn.js): the PLANNER's block, made once from the ledger as it stood — empty when the one setting
1905
+ // (`learningHints` in config.json) is off. A record read, never a verdict: a failure here is no hints, never a failed run.
1906
+ /** @type {{text: string, ids: string[]}|null} */
1907
+ let learnHints = null;
1908
+ if (cfgOn) {
1909
+ try { learnHints = hintsForRun({ home: cfgHome, spec, specHash }); } catch { learnHints = null; }
1910
+ }
1898
1911
  let outcome;
1899
1912
  try {
1900
1913
  outcome = await runJob(spec, {
1901
1914
  stopFile,
1915
+ ...(learnHints === null ? {} : { learnHints }),
1902
1916
  // PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
1903
1917
  // used (never a second `makeSpine(spineFile)` here: two independent
1904
1918
  // emitters against one file would both start their seq counter at 0 and
@@ -2010,6 +2024,20 @@ async function execute(ctx) {
2010
2024
  appendRunEvent({ runid, type: 'settled', by: runid, spentUsd: fin.usd, spendComplete: fin.complete, at: new Date().toISOString(), ...(LEG > 1 ? { leg: LEG } : {}) }, { home: cfgHome });
2011
2025
  } catch (/** @type {any} */ e) { err(`WARNING: could not settle this run's claim in ~/.config/bareloop/runs.jsonl (${e.message}) — the next run settles it once this process is gone.`); }
2012
2026
  }
2027
+ // The run-end tally of cross-run learnings ($0, code only, no model): the whole spine (all legs) and the gate audit become cards
2028
+ // folded into the ONE ledger (a rubric job's are held until the human Accept). Its own try/catch, like the settle above: a
2029
+ // record, never a verdict — a failure prints one WARNING line and can never change the outcome, the exit code or the spend.
2030
+ // `execute` is shared by the panel, the CLI and the bundle runner, so this is the ONE call site. It runs with the hints setting
2031
+ // on or off (OFF only means the planner is handed nothing). With no config home (a test with an injected env) it writes nothing.
2032
+ if (je && cfgOn) {
2033
+ try {
2034
+ const filedAudit = ctx.bundle ? join(spineDir, 'gate-audit.jsonl') : join(spineDir, `u-${runid}-gate-audit.jsonl`);
2035
+ learnAtRunEnd({
2036
+ home: cfgHome, runid, events: allEvents, auditFiles: [filedAudit, join(wd, 'gate-audit.jsonl')], spec, specHash, workdir: wd,
2037
+ trust: quarantinesCredit(spec.verdictType) ? 'rubric' : 'deterministic',
2038
+ });
2039
+ } catch (/** @type {any} */ e) { err(`WARNING: could not record this run's lessons in the learnings ledger (${redactSecrets(String(e?.message ?? e)).split('\n')[0]}) — the run itself is unaffected.`); }
2040
+ }
2013
2041
  // the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
2014
2042
  // here would be the ninth, and one that misses a shape leaks on the very output
2015
2043
  // it guards. Only the COUNT is ever read out; the matches themselves stay here.
package/src/validate.js CHANGED
@@ -167,14 +167,19 @@ export function closeScriptCandidateToken(cmd) {
167
167
  // redactor passes is a leak on the very output the validator was guarding.
168
168
  export const SECRET_PATTERNS = [
169
169
  /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/,
170
- /(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/,
171
- /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/,
172
- /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{16}/,
173
- /(?<![A-Za-z0-9_-])xox[bap]-[A-Za-z0-9-]{10,}/,
170
+ // The prefixes below are distinctive (an ordinary hyphenated word never starts with `ghp_`, `AKIA`, ...), so their left bound
171
+ // drops only the `-`: a key right after a hyphen (`step-ghp_...`) is still a key. `sk-` keeps the full bound above, because
172
+ // `sk-` ends real words (`task-`, `desk-`, `risk-`...). KNOWN LIMIT: an `sk-` key directly after a hyphen or word character
173
+ // (`step-sk-...`) is not found by THIS inventory, so the scan (validator, source door, pagefetch) misses it. (Measured: the
174
+ // redactor still masks it today, but through bareguard's own built-in `sk-` shape, not through this list.)
175
+ /(?<![A-Za-z0-9_])ghp_[A-Za-z0-9]{20,}/,
176
+ /(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{20,}/,
177
+ /(?<![A-Za-z0-9_])AKIA[0-9A-Z]{16}/,
178
+ /(?<![A-Za-z0-9_])xox[bap]-[A-Za-z0-9-]{10,}/,
174
179
  // Google/Gemini API key: `AIza` + 35 chars of [0-9A-Za-z_-] (39 total, fixed
175
180
  // length by Google's own issuance format). Admitted with the gemini-api
176
181
  // provider (PRD item 31.3, F160) — was missing on this branch until then.
177
- /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}/,
182
+ /(?<![A-Za-z0-9_])AIza[0-9A-Za-z_-]{35}/,
178
183
  ];
179
184
  const SECRET_RE = new RegExp(SECRET_PATTERNS.map((r) => r.source).join('|'));
180
185
 
package/src/worktree.js CHANGED
@@ -101,7 +101,8 @@ const COMMIT_CONFIG = [
101
101
  ];
102
102
 
103
103
  /** what never goes into the work commit: the arbiter's own books and installed packages */
104
- const NOT_WORK = [':(exclude).smoke', ':(exclude).litectx', ':(exclude)gate-audit.jsonl', ':(exclude,glob)**/node_modules/**'];
104
+ const NOT_WORK_PATHS = ['.smoke', '.litectx', 'gate-audit.jsonl'];
105
+ const NOT_WORK_GLOB = ':(exclude,glob)**/node_modules/**';
105
106
 
106
107
  /**
107
108
  * The run's final commit, on whatever branch the worktree stands on: everything the run changed except the
@@ -113,7 +114,12 @@ const NOT_WORK = [':(exclude).smoke', ':(exclude).litectx', ':(exclude)gate-audi
113
114
  */
114
115
  export function commitWork(dir, message) {
115
116
  const run = (/** @type {string[]} */ args) => execFileSync('git', ['-C', dir, ...COMMIT_CONFIG, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
116
- run(['add', '-A', '--', '.', ...NOT_WORK]);
117
+ // `git add` exits 1 when an exclude names a path that exists AND is gitignored (it prints "paths are ignored").
118
+ // An ignored path is never staged without -f, so its exclude is redundant: only exclude what git would otherwise add.
119
+ // check-ignore: 0 = ignored, 1 = not ignored, anything else = a real failure (execFileSync throws on 128).
120
+ const ignored = (/** @type {string} */ p) => { try { run(['check-ignore', '-q', '--', p]); return true; } catch (e) { if (/** @type {any} */ (e)?.status === 1) return false; throw e; } };
121
+ const excludes = NOT_WORK_PATHS.filter((p) => !ignored(p)).map((p) => `:(exclude)${p}`);
122
+ run(['add', '-A', '--', '.', ...excludes, NOT_WORK_GLOB]);
117
123
  const staged = run(['diff', '--cached', '--name-only']).trim();
118
124
  if (staged !== '') run(['commit', '-q', '-m', message]);
119
125
  return { committed: staged !== '', sha: run(['rev-parse', '--short', 'HEAD']).trim() };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Cut `text` to at most `max` bytes at a LINE boundary (never mid-line).
3
+ * @param {string} text @param {number} max
4
+ * @returns {{text: string, truncated: boolean, cutLines: number}}
5
+ */
6
+ export function cutAtLine(text: string, max: number): {
7
+ text: string;
8
+ truncated: boolean;
9
+ cutLines: number;
10
+ };
11
+ /**
12
+ * Begin recording one worker try. Call BEFORE the worker's turn; call `finish()` after
13
+ * (in a `finally`). Never throws.
14
+ * @param {{workdir: string, fence: string[], redact?: (s: string) => string}} o
15
+ * `fence` = the ABSOLUTE write-scope prefixes the gate enforces for this try.
16
+ * @returns {Promise<{finish: () => Promise<null | {files: {path: string, diff: string, truncated: boolean, cutLines: number}[], binary: string[], truncated: boolean, gitError?: string}>}>}
17
+ * `finish()` resolves null when the try changed nothing in scope.
18
+ */
19
+ export function startAttemptDiff({ workdir, fence, redact }: {
20
+ workdir: string;
21
+ fence: string[];
22
+ redact?: (s: string) => string;
23
+ }): Promise<{
24
+ finish: () => Promise<null | {
25
+ files: {
26
+ path: string;
27
+ diff: string;
28
+ truncated: boolean;
29
+ cutLines: number;
30
+ }[];
31
+ binary: string[];
32
+ truncated: boolean;
33
+ gitError?: string;
34
+ }>;
35
+ }>;
36
+ /** per-file and per-event byte caps for the recorded diff text */
37
+ export const ATTEMPT_DIFF_FILE_MAX: 4096;
38
+ export const ATTEMPT_DIFF_EVENT_MAX: 16384;
@@ -87,7 +87,8 @@ export function refuseLockedKind(kind: string): Refusal;
87
87
  * rates?: {in: number, out: number}|null, draftThinking?: 'on'|'off',
88
88
  * onPhase?: (phase: string, data?: any) => void,
89
89
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
90
- * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null,
90
+ * seedFn?: Function, scoutFn?: Function, listingFn?: Function, lookFn?: Function|null, fitFn?: Function|null,
91
+ * fitAsk?: ((question: string) => Promise<string|null>)|null, onFit?: (fit: {state: string, asks: any[], notFound: any[]}) => void,
91
92
  * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
92
93
  * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
93
94
  * ask?: ((step: {kind: string, [k: string]: any}) => Promise<string|null>)|null,
@@ -99,7 +100,7 @@ export function refuseLockedKind(kind: string): Refusal;
99
100
  * closeDecl: any, seedRef: string|null, authoring: any, judged: any, reds: Red[],
100
101
  * stop: string|null, cost: any, interview: any, confirmed: any}>}
101
102
  */
102
- export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, jobLines, inputs, generate, provider, seedRef, scout, listing, ceilingUsd, rates, draftThinking, writeScope, judgeModel, onPhase, onCall, seedFn, scoutFn, listingFn, lookFn, authorFn, authorOpts, signerFix, proposeFn, compileOpts, ask, confirmGenerate, isRepo, langResult, answerQuestions, }: {
103
+ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, jobLines, inputs, generate, provider, seedRef, scout, listing, ceilingUsd, rates, draftThinking, writeScope, judgeModel, onPhase, onCall, seedFn, scoutFn, listingFn, lookFn, fitFn, fitAsk, onFit, authorFn, authorOpts, signerFix, proposeFn, compileOpts, ask, confirmGenerate, isRepo, langResult, answerQuestions, }: {
103
104
  answers: Record<string | number, any>;
104
105
  repoPath?: string | null;
105
106
  lang: string;
@@ -137,6 +138,13 @@ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questi
137
138
  scoutFn?: Function;
138
139
  listingFn?: Function;
139
140
  lookFn?: Function | null;
141
+ fitFn?: Function | null;
142
+ fitAsk?: ((question: string) => Promise<string | null>) | null;
143
+ onFit?: (fit: {
144
+ state: string;
145
+ asks: any[];
146
+ notFound: any[];
147
+ }) => void;
140
148
  authorFn?: Function;
141
149
  authorOpts?: object;
142
150
  writeScope?: string[] | null;
package/types/config.d.ts CHANGED
@@ -36,7 +36,7 @@ export function modelsProblem(list: unknown): string | null;
36
36
  * `keys` merges per key variable name, then per field; `models.<ENV>` is REPLACED WHOLE (`null`
37
37
  * clears it). Validated before anything is written; nothing is written on a refusal.
38
38
  *
39
- * Patch fields understood: `monthlyLimitUsd` (finite > 0), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
39
+ * Patch fields understood: `monthlyLimitUsd` (finite > 0), `learningHints` (true | false), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
40
40
  * `models.<ENV>` (a list judged by {@link modelsProblem}). The first write of a row's `models` list
41
41
  * removes that row's older `name` / `priceInPerM` / `priceOutPerM` in the SAME write (never two
42
42
  * sources); a retired top-level `anthropicBalanceNote` is dropped on any write. Any other field is
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What the AI is told: the job lines (the numbered text only, no `~` rules) and the WHOLE text of every input under its label. Adapted from the POC
3
+ * call (.claude/scratch/fit-poc6/run.mjs): the asks are copied from each job line instead of hand-written.
4
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>}} o
5
+ * @returns {string}
6
+ */
7
+ export function fitPrompt({ jobLines, texts }: {
8
+ jobLines: {
9
+ n: number;
10
+ text: string;
11
+ }[];
12
+ texts: Record<string, string>;
13
+ }): string;
14
+ /**
15
+ * Read the AI's reply and decide, all in code. Malformed (no repair): not JSON, another form, a line that is not a job line, an ask not copied from its own
16
+ * line, found not a boolean, a quote neither text nor null, or a job line with no ask. An ask is found only if `found` is true AND its quote is on a page;
17
+ * `found` with a quote that is not on any page (or no quote) is made up and counts as not found.
18
+ * @param {unknown} text the reply
19
+ * @param {{n: number, text: string}[]} jobLines
20
+ * @param {Record<string, string>} texts the frozen input texts by label
21
+ * @returns {{status: 'malformed'}|{status: 'ok', state: 'fits'|'ask', asks: FitAsk[], notFound: {line: number, ask: string}[]}}
22
+ */
23
+ export function decideFit(text: unknown, jobLines: {
24
+ n: number;
25
+ text: string;
26
+ }[], texts: Record<string, string>): {
27
+ status: "malformed";
28
+ } | {
29
+ status: "ok";
30
+ state: "fits" | "ask";
31
+ asks: FitAsk[];
32
+ notFound: {
33
+ line: number;
34
+ ask: string;
35
+ }[];
36
+ };
37
+ /**
38
+ * Run the fit check: one call (one retry on a malformed reply or a transport failure or a 429). Never throws on a call casualty; every failure is `absent`.
39
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>, provider?: any, maxTokens?: number, ceilingUsd?: number|null,
40
+ * callTimeoutMs?: number, onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
41
+ * createLoop?: Function, rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>}} o
42
+ * @returns {Promise<{state: 'fits'|'ask'|'absent', asks: FitAsk[], notFound: {line: number, ask: string}[], reason: string|null, cause: string|null,
43
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[], budgetStop: 'cap-halt'|'pricing-red'|null}>}
44
+ */
45
+ export function runFitCheck({ jobLines, texts, provider, maxTokens, ceilingUsd, callTimeoutMs, onCall, createLoop, rates, callOptions, sleep, }: {
46
+ jobLines: {
47
+ n: number;
48
+ text: string;
49
+ }[];
50
+ texts: Record<string, string>;
51
+ provider?: any;
52
+ maxTokens?: number;
53
+ ceilingUsd?: number | null;
54
+ callTimeoutMs?: number;
55
+ onCall?: (call: {
56
+ label: string;
57
+ costUsd: number | null;
58
+ unpricedRounds: number;
59
+ thinkingStatus?: string;
60
+ raw?: string;
61
+ }) => void;
62
+ createLoop?: Function;
63
+ rates?: {
64
+ in: number;
65
+ out: number;
66
+ } | null;
67
+ callOptions?: Record<string, any>;
68
+ sleep?: (ms: number) => Promise<void>;
69
+ }): Promise<{
70
+ state: "fits" | "ask" | "absent";
71
+ asks: FitAsk[];
72
+ notFound: {
73
+ line: number;
74
+ ask: string;
75
+ }[];
76
+ reason: string | null;
77
+ cause: string | null;
78
+ calls: {
79
+ label: string;
80
+ costUsd: number | null;
81
+ unpricedRounds: number;
82
+ }[];
83
+ budgetStop: "cap-halt" | "pricing-red" | null;
84
+ }>;
85
+ /** the label of the one paid call, as the drafting book and the progress line see it */
86
+ export const FIT_CALL_LABEL: "fit-check";
87
+ /** what the chat says when the check did not finish (drafting goes on) */
88
+ export const FIT_ABSENT_NOTE: "The fit check did not finish; drafting goes on.";
89
+ /** the answer that keeps drafting after the fit question (any case, a closing full stop allowed) */
90
+ export const FIT_GO_ON: "go on";
91
+ export function isGoOn(text: unknown): boolean;
92
+ export const FIT_SYSTEM: "You check whether saved web pages show what a job asks for. You never invent words: every ask must be copied from its job line and every quote must be copied from the page, character for character.";
93
+ export function norm(s: unknown): string;
94
+ export function notFoundList(notFound: {
95
+ line: number;
96
+ ask: string;
97
+ }[]): string;
98
+ export function fitQuestionText({ found, total, notFound }: {
99
+ found: number;
100
+ total: number;
101
+ notFound: {
102
+ line: number;
103
+ ask: string;
104
+ }[];
105
+ }): string;
106
+ export type FitAsk = {
107
+ line: number;
108
+ ask: string;
109
+ found: boolean;
110
+ quote: string | null;
111
+ madeUp: boolean;
112
+ ignored?: "number";
113
+ };
@@ -185,29 +185,6 @@ export function labelListsOf(texts: Record<string, string>): {
185
185
  scans: Record<string, import("./labelscan.js").LabelScan>;
186
186
  lists: Record<string, string>;
187
187
  };
188
- /**
189
- * L3: a reason the AI gave, cut only at a sentence end (never mid-sentence): whole sentences while they fit in `max` characters,
190
- * and nothing at all if the first one alone does not fit. A trailing full stop is dropped (the caller closes the clause).
191
- * @param {string} text @param {number} max @returns {string}
192
- */
193
- export function wholeSentences(text: string, max: number): string;
194
- /**
195
- * I1 (hamr, 2026-10-13, "Ruled by hamr" 2): when the look shows the person's intent on one `~` rule was NOT captured - its words or
196
- * meaning are not in the source - the person is asked about THAT rule at once, named by its line and rule. Only a look entry that names
197
- * a rule number counts; anything else the look could not find is not asked piecemeal (it is Not checked, or the whole job is reviewed).
198
- * The wording is the code's own, never the model's. The keep-or-drop and the cap are `normalizeQuestions` (src/authorflow.js).
199
- * @param {LookNotFound[]} notFound @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
200
- * @returns {{line: number, question: string}[]} every candidate question (the caller applies the machine's caps)
201
- */
202
- export function ruleQuestions(notFound: LookNotFound[], jobLines: {
203
- n: number;
204
- text?: string;
205
- rule?: string;
206
- rules?: string[];
207
- }[]): {
208
- line: number;
209
- question: string;
210
- }[];
211
188
  /** the name of the answer tool - the one place the AI's report enters */
212
189
  export const LOOK_REPORT_TOOL: "report_look";
213
190
  /** how many hits one search returns at most, and how long a snippet or a read line is */
@@ -29,6 +29,12 @@ export const MIN_COUNT: 1;
29
29
  export const MAX_PER_SHAPE: 40;
30
30
  /** only this much of a source is scanned (a source over it is scanned up to a line end) */
31
31
  export const SCAN_MAX_CHARS: 2000000;
32
+ /**
33
+ * The page reader's bracketed tag (`[ref=549]`), shared by the three places that strip it. The lookahead and lookbehind add no match and drop no
34
+ * match: a `[` that sits inside the run of an earlier `[letters=` (no `]` or white space between) is skipped, because that earlier start reads the same
35
+ * run to the same end - it matches (and consumes this one) or fails (and so does this one). Without them "[a=[a=[a=..." with no `]` is quadratic.
36
+ */
37
+ export const TAG: RegExp;
32
38
  export type LabelEntry = {
33
39
  id: string;
34
40
  label: string;