atris 3.74.11 → 3.74.12

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.
@@ -124,6 +124,7 @@ If new evidence changes the task, stop and add a task note instead of forcing co
124
124
  7. **Anti-slop aware**: Read `atris/policies/ANTISLOP.md` before writing. No sparkles, no filler, no purple prose.
125
125
  8. **Stay in scope**: Only touch files listed in the task. If you need to change something outside scope, stop and flag it. That's a new task.
126
126
  9. **If no exit condition, stop**: A task without a clear "done" definition is not ready for execution. Send it back to navigator.
127
+ 10. **Make exactly what was asked**: Match the existing output shape, names, and style. Add no fields, flags, or features the job did not ask for; name them as a next step instead.
127
128
 
128
129
  ---
129
130
 
@@ -976,9 +976,22 @@ function inferMemberRunVerifier(missionText, args = [], cwd = process.cwd()) {
976
976
  if (testFileMatch && fs.existsSync(path.join(cwd, testFileMatch[1]))) {
977
977
  return `node --test ${testFileMatch[1]}`;
978
978
  }
979
+ // A job that names the project's own test command ("keep npm test green")
980
+ // chose that check; it is not a silent broad-suite default.
981
+ const npmMatch = text.match(/\bnpm\s+(run\s+)?test(?![\w:-]|\.\w)/i);
982
+ if (npmMatch && projectHasTestScript(cwd)) return npmMatch[1] ? 'npm run test' : 'npm test';
979
983
  return '';
980
984
  }
981
985
 
986
+ function projectHasTestScript(cwd) {
987
+ try {
988
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
989
+ return Boolean(pkg && pkg.scripts && pkg.scripts.test);
990
+ } catch {
991
+ return false;
992
+ }
993
+ }
994
+
982
995
  function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cwd()) {
983
996
  const resolvedVerifier = inferMemberRunVerifier(missionText, args, cwd);
984
997
  const startArgs = [
@@ -1023,7 +1036,10 @@ function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cw
1023
1036
  // Default to an isolated worktree only when there is a git repo to cut it
1024
1037
  // from. A plain (non-git) workspace would fail every mission start with
1025
1038
  // "fatal: not a git repository", degrade to the shared checkout instead.
1026
- if (!hasFlag(args, '--shared-checkout') && !hasFlag(args, '--no-worktree') && insideGitRepo(cwd)) {
1039
+ // A job that says "do not commit" wants the change in the person's own
1040
+ // files; a side checkout would hide uncommitted work where they never see it.
1041
+ const forbidsCommit = !hasFlag(args, '--worktree') && require('../lib/member-save-work').jobForbidsCommit(missionText);
1042
+ if (!forbidsCommit && !hasFlag(args, '--shared-checkout') && !hasFlag(args, '--no-worktree') && insideGitRepo(cwd)) {
1027
1043
  startArgs.push('--worktree');
1028
1044
  }
1029
1045
  startArgs.push('--json');
@@ -1434,6 +1450,17 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1434
1450
  result.ask = `${name} is waiting on you: ${payload.human_asks[0]}`;
1435
1451
  result.next_command = payload.next_command || 'atris decide';
1436
1452
  }
1453
+ // Passed but never committed: save the loose work on the member's branch so
1454
+ // the branch holds it. Only in the member's own side checkout, never the main one.
1455
+ const worktreePath = finalMission.worktree?.path || mission.worktree?.path || '';
1456
+ if (ok && started && worktreePath && finalMission.verifier_result?.passed === true) {
1457
+ result.saved_work = require('../lib/member-save-work').saveLeftoverMemberWork({
1458
+ worktreePath, member: name, objective: finalMission.objective || mission.objective,
1459
+ });
1460
+ if (result.saved_work.saved && !asJson && !quiet) {
1461
+ console.log(`saved ${result.saved_work.files.length} file(s) ${name} left unsaved onto its branch`);
1462
+ }
1463
+ }
1437
1464
  // A finished ignited run whose check passed hands its branch to GitHub
1438
1465
  // auto-merge, only past the local gates; any held gate leaves the branch.
1439
1466
  if (ignite && ok && started && !reviewAsk && finalMission.verifier_result?.passed === true) {
@@ -9946,13 +9973,24 @@ async function memberChat(name, ...rest) {
9946
9973
 
9947
9974
  const IDEA_ARCHETYPE_RULES = [
9948
9975
  {
9949
- slugs: ['auto-improver', 'improver'],
9950
- pattern: /\b(repeat|recurring|broken\s+test|flaky|error|regression|pain|fix\s+bug|self[- ]improv|crash)\b/i,
9976
+ // A job that opens by asking where or which, or forbids source edits, is
9977
+ // a read-and-answer job even when it mentions a bug.
9978
+ slugs: ['navigator'],
9979
+ // Wins wherever it matches: the question shape decides the whole job.
9980
+ leading: true,
9981
+ pattern: /^(?![\s\S]*(?<!not\s|n't\s)\b(fix|repair|implement|update|change|edit|add|rename|modify)\b)\s*(find|locate|which|count|trace|identify)\b|\bdo\s+not\s+(edit|change|modify)\s+(any\s+)?source\b/i,
9982
+ reason: 'reads the code and answers where and which questions',
9983
+ },
9984
+ {
9985
+ // The shipped `improver` proposes team and workflow changes; it is not a
9986
+ // code fixer, so fix jobs go to auto-improver or fall back to the builder.
9987
+ slugs: ['auto-improver'],
9988
+ pattern: /\b(repeat|recurring|broken|flaky|errors?|regressions?|pain|fix(es|ed|ing)?|bugs?|fail(s|ing|ed|ure)?|self[- ]improv|crash(es|ed|ing)?)\b/i,
9951
9989
  reason: 'handles recurring errors, regressions, and test fixes',
9952
9990
  },
9953
9991
  {
9954
9992
  slugs: ['validator', 'orb'],
9955
- pattern: /\b(review|audit|validate|verify|check\s+diff|quality|slop|security)\b/i,
9993
+ pattern: /\b(code\s+review|review|audit|validate|verify|check\s+diff|quality|slop|security)\b/i,
9956
9994
  reason: 'reviews changes and blocks weak work before merge',
9957
9995
  },
9958
9996
  {
@@ -9988,8 +10026,10 @@ const ARCHETYPE_FOR_JOB = { build: 'executor', review: 'validator', search: 'res
9988
10026
  const EXPERIMENT_STATUSES = new Set(['experimental', 'one-off']);
9989
10027
 
9990
10028
  function ideaRuleFor(slug, record, text) {
9991
- const own = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes(slug));
9992
- if (own && own.pattern.test(text)) return { rule: own, own: true };
10029
+ const ownRules = IDEA_ARCHETYPE_RULES.filter((rule) => rule.slugs.includes(slug));
10030
+ const matched = ownRules.find((rule) => rule.pattern.test(text));
10031
+ if (matched) return { rule: matched, own: true };
10032
+ const own = ownRules[0];
9993
10033
  const { autoJobForMember } = require('../lib/member-engine');
9994
10034
  const archetype = ARCHETYPE_FOR_JOB[autoJobForMember(slug, record.role)];
9995
10035
  const rule = IDEA_ARCHETYPE_RULES.find((r) => r.slugs.includes(archetype)) || null;
@@ -10000,7 +10040,15 @@ function ideaRuleFor(slug, record, text) {
10000
10040
  // specific jobs to the general build job, so "where is the code" is a
10001
10041
  // navigator job even though "code" is also a build word.
10002
10042
  function ideaJobRule(text) {
10003
- return IDEA_ARCHETYPE_RULES.find((rule) => rule.pattern.test(text)) || null;
10043
+ // The job word that comes first names the job: "review the bug fix" is a
10044
+ // review, "fix the review page" is a fix. Ties keep list order.
10045
+ let best = null;
10046
+ for (const rule of IDEA_ARCHETYPE_RULES) {
10047
+ const match = rule.pattern.exec(text);
10048
+ const index = match ? (rule.leading ? -1 : match.index) : null;
10049
+ if (match && (!best || index < best.index)) best = { rule, index };
10050
+ }
10051
+ return best ? best.rule : null;
10004
10052
  }
10005
10053
 
10006
10054
  // 3 = named in the idea, 2 = the job is the member's own, 1 = the member
@@ -10059,7 +10107,12 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
10059
10107
  .toLowerCase()
10060
10108
  .split(/[^a-z0-9]+/)
10061
10109
  .filter((w) => w.length >= 4);
10062
- const jobRule = ideaJobRule(text);
10110
+ let jobRule = ideaJobRule(text);
10111
+ // A job nobody on this team owns goes to the builder rule, not to whoever
10112
+ // shares the most words with it.
10113
+ if (jobRule && !members.some((m) => ideaRuleFor(memberSlugFromRecord(m), m, text).rule === jobRule)) {
10114
+ jobRule = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes('executor')) || jobRule;
10115
+ }
10063
10116
 
10064
10117
  let best = null;
10065
10118
  for (const record of members) {
@@ -10073,6 +10126,9 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
10073
10126
 
10074
10127
  const memberRule = ideaRuleFor(slug, record, text);
10075
10128
  const fit = ideaJobFit(named, memberRule, jobRule);
10129
+ // A reviewer never patches, so it only takes review jobs unless named.
10130
+ const reviewRule = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes('validator'));
10131
+ const effectiveTier = !named && memberRule.rule === reviewRule && jobRule !== reviewRule ? Math.max(tier, 1) : tier;
10076
10132
  if (fit > 0 && memberRule.rule) {
10077
10133
  score += 30;
10078
10134
  matchedReason = memberRule.rule.reason;
@@ -10105,7 +10161,7 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
10105
10161
  role: record.role || 'Team Member',
10106
10162
  reason: matchedReason,
10107
10163
  score,
10108
- tier,
10164
+ tier: effectiveTier,
10109
10165
  fit,
10110
10166
  };
10111
10167
  if (outranksDispatch(candidate, best)) best = candidate;
@@ -10438,7 +10494,7 @@ function resolveMemberEngineSeat(memberSlug, root = process.cwd()) {
10438
10494
 
10439
10495
  function buildMemberSubagentShell(owner, role, engineId, objective, root = process.cwd()) {
10440
10496
  const cleanObjective = compactSentence(objective || `Advance ${owner} roadmap`, 160);
10441
- const verify = inferMemberRunVerifier(owner, cleanObjective, root);
10497
+ const verify = inferMemberRunVerifier(cleanObjective, [], root);
10442
10498
  return {
10443
10499
  role: `${owner} (${role || 'Team Member'})`,
10444
10500
  member: owner,
@@ -12068,6 +12124,7 @@ module.exports = {
12068
12124
  wakeBootLines,
12069
12125
  buildMemberRunStartArgs,
12070
12126
  inferMemberRunVerifier,
12127
+ buildMemberSubagentShell,
12071
12128
  pickMemberForIdea,
12072
12129
  appendMemberGoalLog,
12073
12130
  appendProjectLog,
@@ -2620,7 +2620,8 @@ const MISSION_BARE_COMMAND_WORDS = new Set(['go', 'keep', 'going', 'do', 'it', '
2620
2620
  function missionObjectiveIsJunk(value) {
2621
2621
  const objective = String(value || '').trim();
2622
2622
  if (!objective || objective.startsWith('-')) return true;
2623
- if (/[\u0000-\u001f\u007f-\u009f\ufffd]/u.test(objective)) return true;
2623
+ // Line breaks and tabs are normal in a pasted bug report; other control bytes are junk.
2624
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\ufffd]/u.test(objective)) return true;
2624
2625
  if (objective.replace(/\s/g, '').length < 8) return true;
2625
2626
  const words = objective.toLowerCase().match(/[a-z]+/g) || [];
2626
2627
  return words.length > 0
@@ -9304,6 +9305,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9304
9305
  ``,
9305
9306
  `## Landing rule`,
9306
9307
  `Safe-lane work (internal code, tests, docs, and proofs) must be committed and pushed or landed WITHOUT asking a human. Protected lanes (external sends, payments, credentials, auth/CSP/iframe/sandbox surfaces, and deletes of user data) are never self-landed: file a decision card instead.`,
9308
+ `The objective's own limits win over this rule: if it says do not commit, touch only certain files, or leave something alone, do exactly that.`,
9307
9309
  `After landing a ladder-sourced slice, append a judge card to atris/status/for-you.md in this SHIPPED format:`,
9308
9310
  missionShippedCard(),
9309
9311
  ``,
@@ -446,7 +446,14 @@ function landLocally({ headSha, branch, cwd, pinnedTree }) {
446
446
  function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask, closeTaskIds = [] } = {}) {
447
447
  if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
448
448
  if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
449
- if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
449
+ if (!policyOn(cwd)) {
450
+ // Say where the work is and the one command that brings it in; "self-landing
451
+ // is off" alone left people thinking the fix was already in their files.
452
+ const branch = String(mission.worktree?.branch || '').trim();
453
+ const gate = held('policy', 'self-landing is not switched on in the main checkout');
454
+ if (!branch || branch === mainCheckoutBranch(cwd)) return gate;
455
+ return { ...gate, branch, line: `your files are not changed yet: the work is saved on branch ${branch}. bring it in: git merge ${branch}` };
456
+ }
450
457
 
451
458
  const missionLane = protectedLane({ ...mission, title: mission.objective });
452
459
  if (missionLane) return held('protected', `this mission sits in a protected area (${missionLane})`);
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ // A member that passed its check but never committed leaves its work loose in
8
+ // a side checkout that cleanup later deletes (member lab 2026-10-24: navigator
9
+ // and validator wrote the right files, said done, committed nothing). Commit
10
+ // what is left so the branch holds the work. Atris's own state stays out.
11
+ function git(args, cwd) {
12
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
13
+ return { ok: result.status === 0, stdout: String(result.stdout || '').trim() };
14
+ }
15
+
16
+ function jobForbidsCommit(text) {
17
+ return /\b(do\s+not|don't|never|without|no)\s+(?:(?:create|make|add|any|new|a|an|more)\s+){0,3}commit/i.test(String(text || ''));
18
+ }
19
+
20
+ // Files that look like secrets never ride an automatic commit.
21
+ const SECRET_PATH = /(^|\/)(\.(aws|ssh|gnupg|docker|kube)\/|\.env(\.[^/]*)?$|\.npmrc$|\.netrc$|credentials?$|[^/]*credentials?[^/]*\.(json|ya?ml|txt)$|secrets?\.(json|ya?ml|txt|env)$|[^/]*\.(pem|key|p12|pfx)$|id_(rsa|ed25519|ecdsa)[^/]*$|service[-_]account[^/]*\.json$)/i;
22
+
23
+ function saveLeftoverMemberWork({ worktreePath, member = 'member', objective = '' } = {}) {
24
+ if (!worktreePath) return { saved: false, reason: 'no_worktree' };
25
+ // The job's own words win: "do not commit" means leave the changes loose.
26
+ if (jobForbidsCommit(objective)) return { saved: false, reason: 'job_forbids_commit' };
27
+ if (!git(['rev-parse', '--is-inside-work-tree'], worktreePath).ok) return { saved: false, reason: 'not_git' };
28
+ // Only a linked side checkout: its git dir differs from the shared one. The
29
+ // person's main checkout is never committed into.
30
+ // Plain --git-dir/--git-common-dir work on old git too; resolve both against
31
+ // the checkout, since either may come back relative.
32
+ const gitDirOut = git(['rev-parse', '--git-dir'], worktreePath).stdout;
33
+ const commonDirOut = git(['rev-parse', '--git-common-dir'], worktreePath).stdout;
34
+ if (!gitDirOut || !commonDirOut) return { saved: false, reason: 'not_side_checkout' };
35
+ const real = (dir) => { try { return fs.realpathSync(path.resolve(worktreePath, dir)); } catch { return ''; } };
36
+ const gitDir = real(gitDirOut);
37
+ const commonDir = real(commonDirOut);
38
+ if (!gitDir || !commonDir || gitDir === commonDir) return { saved: false, reason: 'not_side_checkout' };
39
+ const add = git(['add', '-A', '--', '.', ':(exclude).atris', ':(exclude).agent-worktrees'], worktreePath);
40
+ if (!add.ok) return { saved: false, reason: 'add_failed' };
41
+ git(['reset', '-q', '--', '.atris', '.agent-worktrees'], worktreePath);
42
+ const stagedAll = git(['diff', '--cached', '--name-only'], worktreePath).stdout.split('\n').filter(Boolean);
43
+ const skipped = stagedAll.filter((file) => SECRET_PATH.test(file));
44
+ if (skipped.length) git(['reset', '-q', '--', ...skipped], worktreePath);
45
+ const staged = stagedAll.filter((file) => !SECRET_PATH.test(file));
46
+ if (!staged.length) return { saved: false, reason: 'nothing_left', ...(skipped.length ? { skipped } : {}) };
47
+ const title = String(objective || '').split('\n').map((line) => line.trim()).find(Boolean) || 'finished work';
48
+ const message = `${member}: ${title.slice(0, 72)}\n\nSaved by atris after the check passed; the member left these changes uncommitted.`;
49
+ const commit = git(['commit', '-q', '-m', message], worktreePath);
50
+ if (!commit.ok) return { saved: false, reason: 'commit_failed', files: staged };
51
+ return { saved: true, files: staged, ...(skipped.length ? { skipped } : {}), sha: git(['rev-parse', 'HEAD'], worktreePath).stdout };
52
+ }
53
+
54
+ module.exports = { jobForbidsCommit, saveLeftoverMemberWork };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.11",
3
+ "version": "3.74.12",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",