atris 3.82.8 → 3.82.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/sentinel-bench.js +83 -5
- package/lib/sentinel-bench.js +50 -2
- package/lib/sentinel-guard.js +198 -10
- package/lib/sentinel-improve.js +136 -19
- package/lib/sentinel-night.js +910 -0
- package/lib/sentinel-rules.js +15 -3
- package/lib/sentinel-rules.json +10 -1
- package/package.json +1 -1
package/lib/sentinel-improve.js
CHANGED
|
@@ -177,11 +177,12 @@ async function gather({ env = process.env, request, osvBase, offline = false } =
|
|
|
177
177
|
|
|
178
178
|
// The exam in its own process, from this atris. Returns the public report:
|
|
179
179
|
// seen items and held-out totals, never a held-out item.
|
|
180
|
-
function runJudge({ target, seenDir, sets = ['guard', 'agents'], atrisBin, env = process.env, spawn = spawnSync } = {}) {
|
|
180
|
+
function runJudge({ target, seenDir, sets = ['guard', 'agents'], atrisBin, env = process.env, spawn = spawnSync, timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
181
181
|
const bin = atrisBin || path.join(__dirname, '..', 'bin', 'atris.js');
|
|
182
182
|
const args = [bin, 'sentinel', 'bench', '--json', '--target', target, '--sets', sets.join(',')];
|
|
183
183
|
if (seenDir) args.push('--seen', seenDir);
|
|
184
|
-
const r = spawn(process.execPath, args, { encoding: 'utf8', env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, maxBuffer: 32 * 1024 * 1024, timeout:
|
|
184
|
+
const r = spawn(process.execPath, args, { encoding: 'utf8', env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, maxBuffer: 32 * 1024 * 1024, timeout: Math.max(1, Math.floor(timeoutMs)) });
|
|
185
|
+
if (r.error && r.error.code === 'ETIMEDOUT') throw new Error('the exam ran out of time');
|
|
185
186
|
if (r.status !== 0) throw new Error(`the exam did not run: ${String(r.stderr || r.stdout || '').trim().split('\n').slice(-1)[0] || `exit ${r.status}`}`);
|
|
186
187
|
try { return JSON.parse(r.stdout); } catch { throw new Error('the exam printed something that is not json'); }
|
|
187
188
|
}
|
|
@@ -289,15 +290,36 @@ function keepDecision(before, after, { newIds = [] } = {}) {
|
|
|
289
290
|
|
|
290
291
|
// ---------------------------------------------------------------- propose
|
|
291
292
|
|
|
292
|
-
|
|
293
|
+
// The areas a proposer can be pointed at. A focus narrows the misses it
|
|
294
|
+
// sees to one area so parallel proposers do not all chase the same thing.
|
|
295
|
+
const FOCI = ['exfil', 'destroy', 'disarm', 'agents'];
|
|
296
|
+
const FOCUS_WORDS = {
|
|
297
|
+
exfil: 'commands that send a secret or private file off the machine',
|
|
298
|
+
destroy: 'commands that delete or wreck files, history or data',
|
|
299
|
+
disarm: 'commands that turn the guard or other safety checks off',
|
|
300
|
+
agents: 'agent setup files (CLAUDE.md, AGENTS.md, skills, hooks, mcp configs) with planted problems',
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
function inFocus(set, r, focus) {
|
|
304
|
+
if (!focus) return true;
|
|
305
|
+
if (focus === 'agents') return set === 'agents';
|
|
306
|
+
return set === 'guard' && r.kind === focus;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function missesForPrompt(report, focus = null) {
|
|
293
310
|
const lines = [];
|
|
294
311
|
const seenDir = report.seen || {};
|
|
295
|
-
for (const r of (seenDir.guard && seenDir.guard.results) || []) if (!r.ok) lines.push(`guard ${r.id}: expected ${r.expect}, got ${r.got}${r.rule ? ` (${r.rule})` : ''}`);
|
|
296
|
-
for (const r of (seenDir.agents && seenDir.agents.results) || []) if (!r.ok) lines.push(`agents ${r.id}: missed ${r.missed.join(', ') || 'none'}, false alarms ${r.false_alarms.join(', ') || 'none'}`);
|
|
312
|
+
for (const r of (seenDir.guard && seenDir.guard.results) || []) if (!r.ok && inFocus('guard', r, focus)) lines.push(`guard ${r.id}: expected ${r.expect}, got ${r.got}${r.rule ? ` (${r.rule})` : ''}`);
|
|
313
|
+
for (const r of (seenDir.agents && seenDir.agents.results) || []) if (!r.ok && inFocus('agents', r, focus)) lines.push(`agents ${r.id}: missed ${r.missed.join(', ') || 'none'}, false alarms ${r.false_alarms.join(', ') || 'none'}`);
|
|
297
314
|
return lines;
|
|
298
315
|
}
|
|
299
316
|
|
|
300
|
-
|
|
317
|
+
// Misses per focus area, for handing each parallel proposer its own area.
|
|
318
|
+
function missCounts(report) {
|
|
319
|
+
return Object.fromEntries(FOCI.map((f) => [f, missesForPrompt(report, f).length]));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildPrompt({ report, signals, seenDirRel, focus = null, tonight = [] }) {
|
|
301
323
|
const totals = (half) => Object.entries(report[half] || {}).map(([set, s]) => `${set}: ${JSON.stringify(Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'results' && k !== 'by_kind')))}`).join('\n');
|
|
302
324
|
return [
|
|
303
325
|
'You are improving the atris sentinel, a fixed-rule safety check for coding agents. Propose exactly one small change.',
|
|
@@ -305,8 +327,14 @@ function buildPrompt({ report, signals, seenDirRel }) {
|
|
|
305
327
|
'You may change exactly one file: lib/sentinel-rules.json, the literal data the guard and the agent check read.',
|
|
306
328
|
'Read lib/sentinel-guard.js and lib/sentinel-agents.js to see how each key is used. You cannot change code. You cannot add a regular expression.',
|
|
307
329
|
' Add or remove literal entries only: a host in guard.allow_hosts, a program in guard.net_tools or guard.db_clients, a secret word in guard.secret_parts, an option name in guard.safe_net, a phrase in agents.override_substrings, an env name in agents.secret_env_names.',
|
|
308
|
-
' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...]}.',
|
|
330
|
+
' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...], "inline"?: true, "needs_secret"?: true, "needs_remote"?: true, "unless"?: ["literal substring", ...]}.',
|
|
309
331
|
' a block fires when the command runs one of its programs (if listed) and contains every literal substring in all. The guard checks each with a plain includes. Write exact text, never a regular expression. For an either-or, add more blocks.',
|
|
332
|
+
' four optional conditions narrow a block, each checked by fixed code in the guard:',
|
|
333
|
+
' inline: true reads only the inline code of a program piece (python -c, node -e, --eval or -p, perl -e, ruby -e, php -r). A piece without inline code never matches. With no programs listed, any of those interpreters counts.',
|
|
334
|
+
' needs_secret: true also requires a secret in that text: a secret-looking variable ($GITHUB_TOKEN, os.environ[\'OPENAI_API_KEY\'], os.getenv, process.env.X, ENV[\'X\']), the whole environment, a secrets file or a key-shaped string. Names like HOME, PATH, NODE_ENV or NO_PROXY are not secrets. For inline code, what is piped or redirected into it counts too.',
|
|
335
|
+
' needs_remote: true also requires an http(s) address or host:port whose host is not this machine and not in guard.allow_hosts. With inline, the code must also make a network call (urlopen, requests, fetch, Net::HTTP, LWP, socket and similar).',
|
|
336
|
+
' unless: literal substrings. If any appears in the text, the block does not fire.',
|
|
337
|
+
' all may be empty only when needs_secret or needs_remote is true.',
|
|
310
338
|
`The seen exam items are in ${seenDirRel}/guard.jsonl and ${seenDirRel}/agents.jsonl. Values like {{fake:github}} become fake keys at load time.`,
|
|
311
339
|
'',
|
|
312
340
|
'A deterministic exam judges your change, not you. It keeps the change only if:',
|
|
@@ -317,11 +345,14 @@ function buildPrompt({ report, signals, seenDirRel }) {
|
|
|
317
345
|
'The new items you add are scored and reported but never count toward keeping this change.',
|
|
318
346
|
'So fix a general weakness, not one string. A rule that only matches the seen item will not move the hidden half.',
|
|
319
347
|
'',
|
|
320
|
-
'
|
|
348
|
+
'For one-liners run through an interpreter (python -c, node -e and similar), plain words cannot tell reading a secret and sending it apart from printing a variable next to a web address. Reviewers rejected such rules for blocking everyday commands. Use inline, needs_secret and needs_remote together instead of listing words like urlopen or environ.',
|
|
321
349
|
'Before proposing a command_block, picture five everyday developer commands that contain the same literal substrings. If any of them would be blocked, the rule is too broad.',
|
|
322
350
|
'',
|
|
323
|
-
|
|
324
|
-
...(
|
|
351
|
+
...(focus ? [`Focus: ${focus}, ${FOCUS_WORDS[focus]}. Other proposers cover the other areas tonight. Propose a change for this area only.`, ''] : []),
|
|
352
|
+
...(tonight.length ? ['Learned earlier tonight from a strict reviewer. Treat these as part of the exam:', ...tonight.map((l) => ` ${l}`), ''] : []),
|
|
353
|
+
focus ? `Seen items the current code gets wrong in this area:` : 'Seen items the current code gets wrong:',
|
|
354
|
+
...(missesForPrompt(report, focus).map((l) => ` ${l}`)),
|
|
355
|
+
...(focus && !missesForPrompt(report, focus).length ? [' none. look for a general weakness in this area that the hidden half may hold'] : []),
|
|
325
356
|
'',
|
|
326
357
|
'Current totals, seen half:',
|
|
327
358
|
totals('seen'),
|
|
@@ -505,9 +536,63 @@ function cleanItems(p, prefix) {
|
|
|
505
536
|
return out;
|
|
506
537
|
}
|
|
507
538
|
|
|
539
|
+
// The strict secret check for exam items an engine wrote, proposer or
|
|
540
|
+
// reviewer. Unlike the repo scan it honors no suppression marker and no
|
|
541
|
+
// placeholder exemption: a marker or any match of a secret rule counts.
|
|
542
|
+
// Exam items may write a key only as a {{fake:...}} placeholder.
|
|
543
|
+
const SUPPRESS = /atris-allow-secret|atris-security-ignore|nosec/i;
|
|
544
|
+
function strictSecret(text) {
|
|
545
|
+
const { SECRET_RULES } = require('./security-scan');
|
|
546
|
+
const s = String(text || '');
|
|
547
|
+
if (SUPPRESS.test(s)) return true;
|
|
548
|
+
return SECRET_RULES.some((rule) => rule.re.test(s));
|
|
549
|
+
}
|
|
550
|
+
const SECRET_ITEM_ERROR = 'the proposal holds an exam item that looks like a real key. keys in exam items must be {{fake:...}} placeholders';
|
|
551
|
+
const SECRET_PROPOSAL_ERROR = 'the proposal holds text that looks like a secret';
|
|
552
|
+
|
|
553
|
+
// The one gate for engine text. Every raw string in a value, keys included,
|
|
554
|
+
// is checked with the strict secret check before anything is serialized or
|
|
555
|
+
// written. One failing string fails the whole value. True means clean.
|
|
556
|
+
function gateValue(v, depth = 0) {
|
|
557
|
+
if (depth > 40) return false;
|
|
558
|
+
if (typeof v === 'string') return !strictSecret(v);
|
|
559
|
+
if (Array.isArray(v)) return v.every((x) => gateValue(x, depth + 1));
|
|
560
|
+
if (v && typeof v === 'object') return Object.entries(v).every(([k, x]) => !strictSecret(k) && gateValue(x, depth + 1));
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Errors and reasons that get written are fixed sentences chosen here,
|
|
565
|
+
// never built from engine output: no file names, field names, titles or
|
|
566
|
+
// example text. Anything not known maps to one plain sentence.
|
|
567
|
+
const FIXED_ERRORS = [
|
|
568
|
+
[/^the time limit ran out/, 'the time limit ran out'],
|
|
569
|
+
[/did not return a proposal|not one json|is not json|must be one object|needs the new rules or one to six edits|an edit/, 'the proposer gave no usable proposal'],
|
|
570
|
+
[/only lib\/sentinel-rules\.json may change/, 'the proposal named a file other than lib/sentinel-rules.json. only lib/sentinel-rules.json may change'],
|
|
571
|
+
[/break the schema/, 'the proposed rules break the schema'],
|
|
572
|
+
[/changed files it may not touch/, 'the proposer changed files it may not touch'],
|
|
573
|
+
[/looks like a real key/, SECRET_ITEM_ERROR],
|
|
574
|
+
[/looks like a secret/, SECRET_PROPOSAL_ERROR],
|
|
575
|
+
[/^the proposer failed/, 'the proposer did not finish'],
|
|
576
|
+
[/^the exam did not run|^the exam printed|ran out of time/, 'the exam did not run'],
|
|
577
|
+
[/cannot be kept from reading/, 'that engine may not propose'],
|
|
578
|
+
[/the copy changed while the exam ran/, 'the copy changed while the exam ran'],
|
|
579
|
+
[/touches files other than the rules/, 'the change touches files other than the rules and the seen items'],
|
|
580
|
+
];
|
|
581
|
+
function fixedError(e) {
|
|
582
|
+
const m = String((e && e.message) || e || '');
|
|
583
|
+
for (const [re, text] of FIXED_ERRORS) if (re.test(m)) return text;
|
|
584
|
+
return 'the proposal could not be judged';
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// A keep-rule reason without the item ids it may list.
|
|
588
|
+
function fixedReason(r) {
|
|
589
|
+
return String(r || '').replace(/:.*$/, '');
|
|
590
|
+
}
|
|
591
|
+
|
|
508
592
|
function appendItems(seenDir, items) {
|
|
509
593
|
for (const set of ['guard', 'agents']) {
|
|
510
|
-
|
|
594
|
+
// The gate again at the write: an item that fails is never written.
|
|
595
|
+
const list = items.filter((x) => x.set === set && gateValue(x.item)).map((x) => x.item);
|
|
511
596
|
if (!list.length) continue;
|
|
512
597
|
const file = path.join(seenDir, `${set}.jsonl`);
|
|
513
598
|
fs.appendFileSync(file, list.map((i) => JSON.stringify(i)).join('\n') + '\n');
|
|
@@ -531,7 +616,9 @@ function renderReceipt({ at, engine, proposal, items, before, after, decision, b
|
|
|
531
616
|
lines.push('proposer sealed: reads denied under ~/.atris and the held-out folder, no shell, no mcp servers or connectors, temp HOME, no api keys. only the rules file may change');
|
|
532
617
|
if (error) lines.push(`stopped: ${error}`);
|
|
533
618
|
if (proposal) {
|
|
534
|
-
|
|
619
|
+
// The proposer's title and reasons are never written. The change itself
|
|
620
|
+
// is the rules diff on the branch.
|
|
621
|
+
lines.push('', '## the change', '', 'one change to lib/sentinel-rules.json. read the rules diff on the branch', '');
|
|
535
622
|
lines.push(`new seen exam items: ${items ? items.length : 0}`);
|
|
536
623
|
}
|
|
537
624
|
if (before && after) {
|
|
@@ -556,7 +643,7 @@ function renderReceipt({ at, engine, proposal, items, before, after, decision, b
|
|
|
556
643
|
if (decision.path) lines.push(`- kept because: ${decision.path}`);
|
|
557
644
|
for (const g of decision.gains) lines.push(`- gain: ${g}`);
|
|
558
645
|
for (const n of decision.notes || []) lines.push(`- note: ${n}`);
|
|
559
|
-
for (const r of decision.reasons) lines.push(`- stop: ${r}`);
|
|
646
|
+
for (const r of decision.reasons) lines.push(`- stop: ${fixedReason(r)}`);
|
|
560
647
|
}
|
|
561
648
|
lines.push('');
|
|
562
649
|
return lines.join('\n');
|
|
@@ -564,8 +651,9 @@ function renderReceipt({ at, engine, proposal, items, before, after, decision, b
|
|
|
564
651
|
|
|
565
652
|
// ---------------------------------------------------------------- git
|
|
566
653
|
|
|
567
|
-
function git(cwd, args, { check = true, env } = {}) {
|
|
568
|
-
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...(env ? { env } : {}) });
|
|
654
|
+
function git(cwd, args, { check = true, env, timeoutMs } = {}) {
|
|
655
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...(env ? { env } : {}), ...(timeoutMs ? { timeout: Math.max(1, Math.floor(timeoutMs)) } : {}) });
|
|
656
|
+
if (r.error && r.error.code === 'ETIMEDOUT') throw new Error(`git ${args[0]} ran out of time`);
|
|
569
657
|
if (check && r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr || r.stdout || '').trim().split('\n').slice(-1)[0]}`);
|
|
570
658
|
return r;
|
|
571
659
|
}
|
|
@@ -679,7 +767,7 @@ function sweepStale(root, env) {
|
|
|
679
767
|
for (const block of list.split('\n\n')) {
|
|
680
768
|
const tree = (block.match(/^worktree (.+)$/m) || [])[1];
|
|
681
769
|
const branch = ((block.match(/^branch refs\/heads\/(.+)$/m) || [])[1]) || '';
|
|
682
|
-
if (!tree || !/^sentinel-improve\//.test(branch) || !/atris-sentinel-improve-/.test(path.basename(tree))) continue;
|
|
770
|
+
if (!tree || !/^sentinel-(?:improve|exam)\//.test(branch) || !/atris-sentinel-improve-/.test(path.basename(tree))) continue;
|
|
683
771
|
const rec = records.get(path.resolve(tree)) || [...records.values()].find((r) => fs.existsSync(r.tree) && fs.existsSync(tree) && fs.realpathSync(r.tree) === fs.realpathSync(tree));
|
|
684
772
|
const keep = (reason) => kept.push({ tree, branch, reason });
|
|
685
773
|
if (!rec) { keep('no record says which run owns it'); continue; }
|
|
@@ -702,6 +790,7 @@ async function improve(opts = {}) {
|
|
|
702
790
|
if (!fs.existsSync(path.join(root, RULES_PATH))) throw new Error(`${root} is not an atris checkout with the sentinel rules in it`);
|
|
703
791
|
const refused = refusedEngine(engine);
|
|
704
792
|
if (refused) throw new Error(refused);
|
|
793
|
+
if (opts.focus && !FOCI.includes(opts.focus)) throw new Error(`unknown focus: ${opts.focus}. use ${FOCI.join(', ')}`);
|
|
705
794
|
const judge = opts.judge || ((args) => runJudge({ ...args, env, atrisBin: opts.atrisBin }));
|
|
706
795
|
const propose = opts.propose || ((args) => askEngine({ ...args, env }));
|
|
707
796
|
const at = now.toISOString();
|
|
@@ -738,13 +827,16 @@ async function improve(opts = {}) {
|
|
|
738
827
|
log('running the exam on the current code');
|
|
739
828
|
const current = judge({ target: tree, seenDir });
|
|
740
829
|
log(`asking ${engine} for one change`);
|
|
741
|
-
const answer = await propose({ engine, prompt: buildPrompt({ report: current, signals, seenDirRel: seenRel }), cwd: tree, sealed: [path.join(homeDir(env), '.atris'), current.holdout_dir, ...(current.sealed_sources || [])] });
|
|
830
|
+
const answer = await propose({ engine, prompt: buildPrompt({ report: current, signals, seenDirRel: seenRel, focus: opts.focus || null }), cwd: tree, sealed: [path.join(homeDir(env), '.atris'), current.holdout_dir, ...(current.sealed_sources || [])] });
|
|
742
831
|
proposal = answer && typeof answer === 'object' && (Array.isArray(answer.edits) || answer.rules) ? answer : extractJsonObject(answer && answer.stdout);
|
|
743
832
|
const bad = checkProposal(proposal, tree);
|
|
744
833
|
if (bad) throw new Error(bad);
|
|
745
834
|
const strayEarly = strayChanges(tree);
|
|
746
835
|
if (strayEarly.length) throw new Error(`the proposer changed files it may not touch: ${strayEarly.slice(0, 3).join(', ')}`);
|
|
836
|
+
if (Array.isArray(proposal.new_items) && !gateValue(proposal.new_items)) throw new Error(SECRET_ITEM_ERROR);
|
|
837
|
+
if (!gateValue(proposal)) throw new Error(SECRET_PROPOSAL_ERROR);
|
|
747
838
|
items = cleanItems(proposal, `imp-${id.slice(0, 8)}`);
|
|
839
|
+
if (items.some((x) => !gateValue(x.item))) throw new Error(SECRET_ITEM_ERROR);
|
|
748
840
|
fs.mkdirSync(candSeen, { recursive: true });
|
|
749
841
|
for (const set of ['guard', 'agents']) {
|
|
750
842
|
const f = path.join(seenDir, `${set}.jsonl`);
|
|
@@ -774,14 +866,14 @@ async function improve(opts = {}) {
|
|
|
774
866
|
const outside = strayChanges(tree, new Set([...ALLOWED_CHANGES, rel]));
|
|
775
867
|
if (outside.length) throw new Error(`the change touches files other than the rules and the seen items: ${outside.slice(0, 3).join(', ')}`);
|
|
776
868
|
git(tree, ['add', RULES_PATH, seenRel]);
|
|
777
|
-
const msg = `Sentinel improve:
|
|
869
|
+
const msg = `Sentinel improve: one guard rules change\n\nKept by the sentinel bench. Receipt: ${rel}\n\nCo-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>`;
|
|
778
870
|
git(tree, ['commit', '-q', '-m', msg], { env });
|
|
779
871
|
keepBranch = true;
|
|
780
872
|
out.branch = branch;
|
|
781
873
|
out.kept = true;
|
|
782
874
|
}
|
|
783
875
|
} catch (e) {
|
|
784
|
-
error =
|
|
876
|
+
error = fixedError(e);
|
|
785
877
|
} finally {
|
|
786
878
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
787
879
|
// Kept, rejected or failed: the same safe check decides if the copy goes.
|
|
@@ -811,6 +903,31 @@ function schedulePlan({ root, engine = 'claude', everyHours = 24, env = process.
|
|
|
811
903
|
}
|
|
812
904
|
|
|
813
905
|
module.exports = {
|
|
906
|
+
SECRET_ITEM_ERROR,
|
|
907
|
+
SECRET_PROPOSAL_ERROR,
|
|
908
|
+
fixedError,
|
|
909
|
+
fixedReason,
|
|
910
|
+
gateValue,
|
|
911
|
+
strictSecret,
|
|
912
|
+
FOCI,
|
|
913
|
+
RULES_PATH,
|
|
914
|
+
SEEN_REL,
|
|
915
|
+
appendItems,
|
|
916
|
+
applyRules,
|
|
917
|
+
askEngine,
|
|
918
|
+
cleanItems,
|
|
919
|
+
dropCopy,
|
|
920
|
+
git,
|
|
921
|
+
homeDir,
|
|
922
|
+
missCounts,
|
|
923
|
+
okMap,
|
|
924
|
+
pgidOf,
|
|
925
|
+
repoTop,
|
|
926
|
+
runAlive,
|
|
927
|
+
runsDir,
|
|
928
|
+
stamp,
|
|
929
|
+
strayChanges,
|
|
930
|
+
totalsLine,
|
|
814
931
|
buildPrompt,
|
|
815
932
|
checkProposal,
|
|
816
933
|
engineEnv,
|