atris 3.74.11 → 3.74.13
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/atris/team/executor/MEMBER.md +1 -0
- package/commands/member.js +87 -14
- package/commands/mission.js +74 -3
- package/lib/member-autoland.js +8 -1
- package/lib/member-save-work.js +54 -0
- package/lib/zen-apply.js +12 -4
- package/lib/zen.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
package/commands/member.js
CHANGED
|
@@ -785,6 +785,7 @@ const MEMBER_RUN_START_BOOLEAN_FLAGS = [
|
|
|
785
785
|
'--json',
|
|
786
786
|
'--no-verify',
|
|
787
787
|
'--referee',
|
|
788
|
+
'--referee-if-check-edited',
|
|
788
789
|
'--no-referee',
|
|
789
790
|
'--worktree',
|
|
790
791
|
'--shared-checkout',
|
|
@@ -976,9 +977,22 @@ function inferMemberRunVerifier(missionText, args = [], cwd = process.cwd()) {
|
|
|
976
977
|
if (testFileMatch && fs.existsSync(path.join(cwd, testFileMatch[1]))) {
|
|
977
978
|
return `node --test ${testFileMatch[1]}`;
|
|
978
979
|
}
|
|
980
|
+
// A job that names the project's own test command ("keep npm test green")
|
|
981
|
+
// chose that check; it is not a silent broad-suite default.
|
|
982
|
+
const npmMatch = text.match(/\bnpm\s+(run\s+)?test(?![\w:-]|\.\w)/i);
|
|
983
|
+
if (npmMatch && projectHasTestScript(cwd)) return npmMatch[1] ? 'npm run test' : 'npm test';
|
|
979
984
|
return '';
|
|
980
985
|
}
|
|
981
986
|
|
|
987
|
+
function projectHasTestScript(cwd) {
|
|
988
|
+
try {
|
|
989
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
990
|
+
return Boolean(pkg && pkg.scripts && pkg.scripts.test);
|
|
991
|
+
} catch {
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
982
996
|
function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cwd()) {
|
|
983
997
|
const resolvedVerifier = inferMemberRunVerifier(missionText, args, cwd);
|
|
984
998
|
const startArgs = [
|
|
@@ -1023,7 +1037,10 @@ function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cw
|
|
|
1023
1037
|
// Default to an isolated worktree only when there is a git repo to cut it
|
|
1024
1038
|
// from. A plain (non-git) workspace would fail every mission start with
|
|
1025
1039
|
// "fatal: not a git repository", degrade to the shared checkout instead.
|
|
1026
|
-
|
|
1040
|
+
// A job that says "do not commit" wants the change in the person's own
|
|
1041
|
+
// files; a side checkout would hide uncommitted work where they never see it.
|
|
1042
|
+
const forbidsCommit = !hasFlag(args, '--worktree') && require('../lib/member-save-work').jobForbidsCommit(missionText);
|
|
1043
|
+
if (!forbidsCommit && !hasFlag(args, '--shared-checkout') && !hasFlag(args, '--no-worktree') && insideGitRepo(cwd)) {
|
|
1027
1044
|
startArgs.push('--worktree');
|
|
1028
1045
|
}
|
|
1029
1046
|
startArgs.push('--json');
|
|
@@ -1048,9 +1065,21 @@ function startMemberRunMission(name, missionText, args = []) {
|
|
|
1048
1065
|
}
|
|
1049
1066
|
|
|
1050
1067
|
// An ignited run asks a second engine to review the work by default, so every
|
|
1051
|
-
// run ends with a verdict.
|
|
1052
|
-
|
|
1053
|
-
|
|
1068
|
+
// run ends with a verdict. A real test command is already that verdict: the
|
|
1069
|
+
// second engine only reran the same commands and cost about a minute a run
|
|
1070
|
+
// (member lab 2026-10-24), so it stays for runs whose check is not a test
|
|
1071
|
+
// runner (whitespace, syntax, or file-exists checks). --no-referee skips it; an explicit --referee is kept.
|
|
1072
|
+
function memberRunHasRealCheck(verifier) {
|
|
1073
|
+
const command = String(verifier || '').trim();
|
|
1074
|
+
// A plain test runner only: chaining (|| true, ; , &&, pipes, subshells) or a
|
|
1075
|
+
// pass-with-no-tests switch can make any run "pass", so those keep the review.
|
|
1076
|
+
if (/\|\||&&|[;|`\r\n]|\$\(|pass-?with-?no-?tests|--no-tests|--exit-zero/i.test(command)) return false;
|
|
1077
|
+
return /^((npm|pnpm|yarn)\s+(run\s+)?test\b|node\s+--test\b|npx\s+(jest|vitest|mocha)\b|pytest\b|python3?\s+-m\s+pytest\b|go\s+test\b|cargo\s+test\b)/i.test(command);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function memberRunWantsReferee(runArgs, ignite, verifier = '') {
|
|
1081
|
+
return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee')
|
|
1082
|
+
&& !memberRunHasRealCheck(verifier);
|
|
1054
1083
|
}
|
|
1055
1084
|
|
|
1056
1085
|
function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
|
|
@@ -1377,7 +1406,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
|
|
|
1377
1406
|
['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
|
|
1378
1407
|
['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
|
|
1379
1408
|
if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
|
|
1380
|
-
if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
|
|
1409
|
+
if (memberRunWantsReferee(runArgs, ignite, mission.verifier)) runArgs.push('--referee');
|
|
1410
|
+
// Skipped because a real test is the check: review anyway if the worker
|
|
1411
|
+
// edits what that test command runs.
|
|
1412
|
+
else if (ignite && memberRunHasRealCheck(mission.verifier) && !hasFlag(runArgs, '--no-referee')) runArgs.push('--referee-if-check-edited');
|
|
1381
1413
|
const budgetSeconds = memberRunBudgetSeconds(args);
|
|
1382
1414
|
if (!readFlag(runArgs, '--max-ticks', '')) {
|
|
1383
1415
|
runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
|
|
@@ -1434,6 +1466,17 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
|
|
|
1434
1466
|
result.ask = `${name} is waiting on you: ${payload.human_asks[0]}`;
|
|
1435
1467
|
result.next_command = payload.next_command || 'atris decide';
|
|
1436
1468
|
}
|
|
1469
|
+
// Passed but never committed: save the loose work on the member's branch so
|
|
1470
|
+
// the branch holds it. Only in the member's own side checkout, never the main one.
|
|
1471
|
+
const worktreePath = finalMission.worktree?.path || mission.worktree?.path || '';
|
|
1472
|
+
if (ok && started && worktreePath && finalMission.verifier_result?.passed === true) {
|
|
1473
|
+
result.saved_work = require('../lib/member-save-work').saveLeftoverMemberWork({
|
|
1474
|
+
worktreePath, member: name, objective: finalMission.objective || mission.objective,
|
|
1475
|
+
});
|
|
1476
|
+
if (result.saved_work.saved && !asJson && !quiet) {
|
|
1477
|
+
console.log(`saved ${result.saved_work.files.length} file(s) ${name} left unsaved onto its branch`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1437
1480
|
// A finished ignited run whose check passed hands its branch to GitHub
|
|
1438
1481
|
// auto-merge, only past the local gates; any held gate leaves the branch.
|
|
1439
1482
|
if (ignite && ok && started && !reviewAsk && finalMission.verifier_result?.passed === true) {
|
|
@@ -9946,13 +9989,24 @@ async function memberChat(name, ...rest) {
|
|
|
9946
9989
|
|
|
9947
9990
|
const IDEA_ARCHETYPE_RULES = [
|
|
9948
9991
|
{
|
|
9949
|
-
|
|
9950
|
-
|
|
9992
|
+
// A job that opens by asking where or which, or forbids source edits, is
|
|
9993
|
+
// a read-and-answer job even when it mentions a bug.
|
|
9994
|
+
slugs: ['navigator'],
|
|
9995
|
+
// Wins wherever it matches: the question shape decides the whole job.
|
|
9996
|
+
leading: true,
|
|
9997
|
+
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,
|
|
9998
|
+
reason: 'reads the code and answers where and which questions',
|
|
9999
|
+
},
|
|
10000
|
+
{
|
|
10001
|
+
// The shipped `improver` proposes team and workflow changes; it is not a
|
|
10002
|
+
// code fixer, so fix jobs go to auto-improver or fall back to the builder.
|
|
10003
|
+
slugs: ['auto-improver'],
|
|
10004
|
+
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
10005
|
reason: 'handles recurring errors, regressions, and test fixes',
|
|
9952
10006
|
},
|
|
9953
10007
|
{
|
|
9954
10008
|
slugs: ['validator', 'orb'],
|
|
9955
|
-
pattern: /\b(review|audit|validate|verify|check\s+diff|quality|slop|security)\b/i,
|
|
10009
|
+
pattern: /\b(code\s+review|review|audit|validate|verify|check\s+diff|quality|slop|security)\b/i,
|
|
9956
10010
|
reason: 'reviews changes and blocks weak work before merge',
|
|
9957
10011
|
},
|
|
9958
10012
|
{
|
|
@@ -9988,8 +10042,10 @@ const ARCHETYPE_FOR_JOB = { build: 'executor', review: 'validator', search: 'res
|
|
|
9988
10042
|
const EXPERIMENT_STATUSES = new Set(['experimental', 'one-off']);
|
|
9989
10043
|
|
|
9990
10044
|
function ideaRuleFor(slug, record, text) {
|
|
9991
|
-
const
|
|
9992
|
-
|
|
10045
|
+
const ownRules = IDEA_ARCHETYPE_RULES.filter((rule) => rule.slugs.includes(slug));
|
|
10046
|
+
const matched = ownRules.find((rule) => rule.pattern.test(text));
|
|
10047
|
+
if (matched) return { rule: matched, own: true };
|
|
10048
|
+
const own = ownRules[0];
|
|
9993
10049
|
const { autoJobForMember } = require('../lib/member-engine');
|
|
9994
10050
|
const archetype = ARCHETYPE_FOR_JOB[autoJobForMember(slug, record.role)];
|
|
9995
10051
|
const rule = IDEA_ARCHETYPE_RULES.find((r) => r.slugs.includes(archetype)) || null;
|
|
@@ -10000,7 +10056,15 @@ function ideaRuleFor(slug, record, text) {
|
|
|
10000
10056
|
// specific jobs to the general build job, so "where is the code" is a
|
|
10001
10057
|
// navigator job even though "code" is also a build word.
|
|
10002
10058
|
function ideaJobRule(text) {
|
|
10003
|
-
|
|
10059
|
+
// The job word that comes first names the job: "review the bug fix" is a
|
|
10060
|
+
// review, "fix the review page" is a fix. Ties keep list order.
|
|
10061
|
+
let best = null;
|
|
10062
|
+
for (const rule of IDEA_ARCHETYPE_RULES) {
|
|
10063
|
+
const match = rule.pattern.exec(text);
|
|
10064
|
+
const index = match ? (rule.leading ? -1 : match.index) : null;
|
|
10065
|
+
if (match && (!best || index < best.index)) best = { rule, index };
|
|
10066
|
+
}
|
|
10067
|
+
return best ? best.rule : null;
|
|
10004
10068
|
}
|
|
10005
10069
|
|
|
10006
10070
|
// 3 = named in the idea, 2 = the job is the member's own, 1 = the member
|
|
@@ -10059,7 +10123,12 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
|
|
|
10059
10123
|
.toLowerCase()
|
|
10060
10124
|
.split(/[^a-z0-9]+/)
|
|
10061
10125
|
.filter((w) => w.length >= 4);
|
|
10062
|
-
|
|
10126
|
+
let jobRule = ideaJobRule(text);
|
|
10127
|
+
// A job nobody on this team owns goes to the builder rule, not to whoever
|
|
10128
|
+
// shares the most words with it.
|
|
10129
|
+
if (jobRule && !members.some((m) => ideaRuleFor(memberSlugFromRecord(m), m, text).rule === jobRule)) {
|
|
10130
|
+
jobRule = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes('executor')) || jobRule;
|
|
10131
|
+
}
|
|
10063
10132
|
|
|
10064
10133
|
let best = null;
|
|
10065
10134
|
for (const record of members) {
|
|
@@ -10073,6 +10142,9 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
|
|
|
10073
10142
|
|
|
10074
10143
|
const memberRule = ideaRuleFor(slug, record, text);
|
|
10075
10144
|
const fit = ideaJobFit(named, memberRule, jobRule);
|
|
10145
|
+
// A reviewer never patches, so it only takes review jobs unless named.
|
|
10146
|
+
const reviewRule = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes('validator'));
|
|
10147
|
+
const effectiveTier = !named && memberRule.rule === reviewRule && jobRule !== reviewRule ? Math.max(tier, 1) : tier;
|
|
10076
10148
|
if (fit > 0 && memberRule.rule) {
|
|
10077
10149
|
score += 30;
|
|
10078
10150
|
matchedReason = memberRule.rule.reason;
|
|
@@ -10105,7 +10177,7 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
|
|
|
10105
10177
|
role: record.role || 'Team Member',
|
|
10106
10178
|
reason: matchedReason,
|
|
10107
10179
|
score,
|
|
10108
|
-
tier,
|
|
10180
|
+
tier: effectiveTier,
|
|
10109
10181
|
fit,
|
|
10110
10182
|
};
|
|
10111
10183
|
if (outranksDispatch(candidate, best)) best = candidate;
|
|
@@ -10438,7 +10510,7 @@ function resolveMemberEngineSeat(memberSlug, root = process.cwd()) {
|
|
|
10438
10510
|
|
|
10439
10511
|
function buildMemberSubagentShell(owner, role, engineId, objective, root = process.cwd()) {
|
|
10440
10512
|
const cleanObjective = compactSentence(objective || `Advance ${owner} roadmap`, 160);
|
|
10441
|
-
const verify = inferMemberRunVerifier(
|
|
10513
|
+
const verify = inferMemberRunVerifier(cleanObjective, [], root);
|
|
10442
10514
|
return {
|
|
10443
10515
|
role: `${owner} (${role || 'Team Member'})`,
|
|
10444
10516
|
member: owner,
|
|
@@ -12068,6 +12140,7 @@ module.exports = {
|
|
|
12068
12140
|
wakeBootLines,
|
|
12069
12141
|
buildMemberRunStartArgs,
|
|
12070
12142
|
inferMemberRunVerifier,
|
|
12143
|
+
buildMemberSubagentShell,
|
|
12071
12144
|
pickMemberForIdea,
|
|
12072
12145
|
appendMemberGoalLog,
|
|
12073
12146
|
appendProjectLog,
|
package/commands/mission.js
CHANGED
|
@@ -788,6 +788,7 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
|
|
|
788
788
|
'--no-claude',
|
|
789
789
|
'--no-verify',
|
|
790
790
|
'--referee',
|
|
791
|
+
'--referee-if-check-edited',
|
|
791
792
|
'--no-referee',
|
|
792
793
|
'--complete-on-pass',
|
|
793
794
|
'--no-drain',
|
|
@@ -2620,7 +2621,8 @@ const MISSION_BARE_COMMAND_WORDS = new Set(['go', 'keep', 'going', 'do', 'it', '
|
|
|
2620
2621
|
function missionObjectiveIsJunk(value) {
|
|
2621
2622
|
const objective = String(value || '').trim();
|
|
2622
2623
|
if (!objective || objective.startsWith('-')) return true;
|
|
2623
|
-
|
|
2624
|
+
// Line breaks and tabs are normal in a pasted bug report; other control bytes are junk.
|
|
2625
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\ufffd]/u.test(objective)) return true;
|
|
2624
2626
|
if (objective.replace(/\s/g, '').length < 8) return true;
|
|
2625
2627
|
const words = objective.toLowerCase().match(/[a-z]+/g) || [];
|
|
2626
2628
|
return words.length > 0
|
|
@@ -6949,6 +6951,39 @@ function missionProtectedTags(mission, root = process.cwd()) {
|
|
|
6949
6951
|
return tags;
|
|
6950
6952
|
}
|
|
6951
6953
|
|
|
6954
|
+
// True when the tick changed what its check command runs: package.json (which
|
|
6955
|
+
// defines npm test) or a file the command names. A passing test the worker
|
|
6956
|
+
// rewrote is not evidence on its own.
|
|
6957
|
+
function tickTouchedCheckDefinition(verifier, before, root = process.cwd()) {
|
|
6958
|
+
const run = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
6959
|
+
const head = String(before?.head || '').trim();
|
|
6960
|
+
const changed = [
|
|
6961
|
+
// No starting commit to compare against: every tracked file counts.
|
|
6962
|
+
...String(run(head ? ['diff', '--name-only', head] : ['ls-files']).stdout || '').split('\n'),
|
|
6963
|
+
...String(run(['diff', '--name-only', 'HEAD']).stdout || '').split('\n'),
|
|
6964
|
+
...String(run(['ls-files', '--others', '--exclude-standard']).stdout || '').split('\n'),
|
|
6965
|
+
].map((file) => file.trim()).filter(Boolean);
|
|
6966
|
+
// npm test runs whatever package.json says, so its paths and folders count too.
|
|
6967
|
+
let checkText = String(verifier || '');
|
|
6968
|
+
if (/^(npm|pnpm|yarn)\s/i.test(checkText.trim())) {
|
|
6969
|
+
try { checkText += ` ${JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))?.scripts?.test || ''}`; } catch {}
|
|
6970
|
+
}
|
|
6971
|
+
// Runners that find tests by name (node --test, jest, pytest, go test) never
|
|
6972
|
+
// list them, so any changed file shaped like a test counts as well.
|
|
6973
|
+
const testShaped = /(^|\/)(tests?|__tests__|specs?|checks?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$|(^|\/)conftest\.py$/i;
|
|
6974
|
+
return changed.some((file) => path.basename(file) === 'package.json'
|
|
6975
|
+
|| testShaped.test(file)
|
|
6976
|
+
|| checkText.includes(file)
|
|
6977
|
+
|| (path.dirname(file) !== '.' && checkText.includes(`${path.dirname(file)}/`)));
|
|
6978
|
+
}
|
|
6979
|
+
|
|
6980
|
+
// The worker's `learned:` line from its receipt, so the lesson reaches the
|
|
6981
|
+
// member log (and the next worker's preloaded memory) without a hand edit.
|
|
6982
|
+
function receiptLearnedLine(text) {
|
|
6983
|
+
const match = String(text || '').match(/^[\s*-]*learned:\s*(.+)$/im);
|
|
6984
|
+
return match ? match[1].trim().slice(0, 240) : undefined;
|
|
6985
|
+
}
|
|
6986
|
+
|
|
6952
6987
|
function inspectMissionTickProtectedDiff(mission, before, root = process.cwd()) {
|
|
6953
6988
|
const after = gitWorktreeSnapshot(root);
|
|
6954
6989
|
if (before?.reason === 'not-git-worktree' && after?.reason === 'not-git-worktree') {
|
|
@@ -9152,6 +9187,31 @@ function readBoundedContextFile(filePath, maxChars = 1600) {
|
|
|
9152
9187
|
}
|
|
9153
9188
|
}
|
|
9154
9189
|
|
|
9190
|
+
// Code members never call the account-wide tool servers (mail, calendar,
|
|
9191
|
+
// docs), yet loading them cost about 5s per worker start in the member lab.
|
|
9192
|
+
// They keep the project's own .mcp.json; ATRIS_MEMBER_KEEP_MCP=1 keeps all.
|
|
9193
|
+
const CODE_MEMBERS = new Set(['executor', 'builder', 'codex-executor', 'auto-improver', 'navigator', 'validator', 'architect']);
|
|
9194
|
+
|
|
9195
|
+
function claudeRunnerMcpArgs(mission, cwd = process.cwd(), env = process.env) {
|
|
9196
|
+
if (env.ATRIS_MEMBER_KEEP_MCP === '1') return [];
|
|
9197
|
+
if (!CODE_MEMBERS.has(String(mission?.owner || '').trim().toLowerCase())) return [];
|
|
9198
|
+
const args = ['--strict-mcp-config'];
|
|
9199
|
+
const projectConfig = path.join(cwd, '.mcp.json');
|
|
9200
|
+
if (fs.existsSync(projectConfig)) args.push('--mcp-config', projectConfig);
|
|
9201
|
+
// Servers added for this folder with Claude's local scope live in
|
|
9202
|
+
// ~/.claude.json under the folder's path; keep those too.
|
|
9203
|
+
try {
|
|
9204
|
+
const home = env.HOME || os.homedir();
|
|
9205
|
+
const projects = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))?.projects || {};
|
|
9206
|
+
// A side checkout's servers are usually registered under the main checkout.
|
|
9207
|
+
const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' });
|
|
9208
|
+
const mainRoot = common.status === 0 ? path.dirname(path.resolve(cwd, String(common.stdout || '').trim())) : '';
|
|
9209
|
+
const local = { ...(mainRoot ? projects[mainRoot]?.mcpServers : null), ...projects[path.resolve(cwd)]?.mcpServers };
|
|
9210
|
+
if (Object.keys(local).length) args.push('--mcp-config', JSON.stringify({ mcpServers: local }));
|
|
9211
|
+
} catch {}
|
|
9212
|
+
return args;
|
|
9213
|
+
}
|
|
9214
|
+
|
|
9155
9215
|
function buildPreloadedMemberContextLines(mission, root = process.cwd()) {
|
|
9156
9216
|
const owner = String(mission?.owner || '').trim();
|
|
9157
9217
|
const baseRoot = mission?.worktree_root || mission?.worktree?.path || root;
|
|
@@ -9281,7 +9341,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
9281
9341
|
``,
|
|
9282
9342
|
`## Your task`,
|
|
9283
9343
|
`Do ONE increment of work toward the stop condition. ONE. No more.`,
|
|
9284
|
-
`- ${memberIdentityLine(mission.owner)}
|
|
9344
|
+
`- ${memberIdentityLine(mission.owner)} Your member files are preloaded above; open atris/team/${mission.owner}/MEMBER.md only if that copy is marked truncated. Work in that identity, inside its scope and stop rules. Atris writes your member log from your receipt, so end the receipt with one \`learned: <one line>\` line instead of editing the log.`,
|
|
9285
9345
|
`- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
|
|
9286
9346
|
`- Pick the smallest concrete action that moves the mission forward.`,
|
|
9287
9347
|
`- Before acting, state your single next move in one sentence.`,
|
|
@@ -9289,7 +9349,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
9289
9349
|
`- The tick is not done until the verify step passes. If a frozen verifier exists, the harness runs it, but that does not replace the real-surface proof below. If no frozen verifier exists, finish by actually running the surface you changed on this computer and paste the real command output into your receipt.`,
|
|
9290
9350
|
`- After that one verify step, STOP. Never start a second slice in one tick.`,
|
|
9291
9351
|
`- Write the tick summary in operator language: what changed and what it buys or costs in plain words, with no flags, task ids, or code identifiers.`,
|
|
9292
|
-
`- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt
|
|
9352
|
+
`- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt so the team compounds exponentially.`,
|
|
9293
9353
|
``,
|
|
9294
9354
|
`## No claimable work? Work the backstop ladder`,
|
|
9295
9355
|
`If the mission and task state offer no claimable work, inspect these rungs in order. Pick the FIRST rung with real fuel and do ONE bounded slice:`,
|
|
@@ -9304,6 +9364,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
9304
9364
|
``,
|
|
9305
9365
|
`## Landing rule`,
|
|
9306
9366
|
`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.`,
|
|
9367
|
+
`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
9368
|
`After landing a ladder-sourced slice, append a judge card to atris/status/for-you.md in this SHIPPED format:`,
|
|
9308
9369
|
missionShippedCard(),
|
|
9309
9370
|
``,
|
|
@@ -9684,6 +9745,7 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9684
9745
|
result: finalText,
|
|
9685
9746
|
summary: usefulClaudeReceiptSummary(finalText || errStr, ok ? 'no-text' : 'error'),
|
|
9686
9747
|
receipt_text: cappedClaudeReceiptText(finalText || errStr),
|
|
9748
|
+
learned: receiptLearnedLine(finalText),
|
|
9687
9749
|
duration_total_ms: Date.now() - startedAt,
|
|
9688
9750
|
num_turns: null,
|
|
9689
9751
|
stop_reason: null,
|
|
@@ -9773,6 +9835,7 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9773
9835
|
];
|
|
9774
9836
|
if (model) args.push('--model', model);
|
|
9775
9837
|
if (mission.roster_effort) args.push('--effort', mission.roster_effort);
|
|
9838
|
+
args.push(...claudeRunnerMcpArgs(mission, cwd));
|
|
9776
9839
|
if (sessionMode === 'set') args.push('--session-id', sessionId);
|
|
9777
9840
|
else if (sessionMode === 'resume') args.push('--resume', sessionId);
|
|
9778
9841
|
|
|
@@ -9879,6 +9942,7 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9879
9942
|
result: finalText,
|
|
9880
9943
|
summary: usefulClaudeReceiptSummary(finalText, ok ? 'no-text' : 'error'),
|
|
9881
9944
|
receipt_text: cappedClaudeReceiptText(finalText),
|
|
9945
|
+
learned: receiptLearnedLine(finalText),
|
|
9882
9946
|
api_equivalent_estimate: costEstimate,
|
|
9883
9947
|
duration_api_ms: durationApiMs,
|
|
9884
9948
|
duration_total_ms: Date.now() - startedAt,
|
|
@@ -10122,6 +10186,7 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10122
10186
|
const skipClaude = hasFlag(args, '--no-claude');
|
|
10123
10187
|
const verifyEach = !hasFlag(args, '--no-verify');
|
|
10124
10188
|
const refereeEach = hasFlag(args, '--referee') && !hasFlag(args, '--no-referee');
|
|
10189
|
+
const refereeIfCheckEdited = hasFlag(args, '--referee-if-check-edited') && !hasFlag(args, '--no-referee');
|
|
10125
10190
|
const refereeEngine = readFlag(args, '--referee-engine', '');
|
|
10126
10191
|
const completeOnPass = hasFlag(args, '--complete-on-pass');
|
|
10127
10192
|
const skipDrain = hasFlag(args, '--no-drain');
|
|
@@ -10159,6 +10224,7 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10159
10224
|
skipClaude,
|
|
10160
10225
|
verifyEach,
|
|
10161
10226
|
refereeEach,
|
|
10227
|
+
refereeIfCheckEdited,
|
|
10162
10228
|
refereeEngine,
|
|
10163
10229
|
completeOnPass,
|
|
10164
10230
|
skipDrain,
|
|
@@ -10993,6 +11059,7 @@ async function executeMissionRunTicksPhase(context) {
|
|
|
10993
11059
|
verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
|
|
10994
11060
|
summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
|
|
10995
11061
|
next_push: result.next_push ? String(result.next_push).slice(0, 180) : undefined,
|
|
11062
|
+
learned: result?.claude?.learned || receiptLearnedLine(result?.claude?.result || result?.claude?.receipt_text),
|
|
10996
11063
|
feedback: verifierResult && !verifierResult.passed ? String(mission.last_check_feedback || '').slice(0, 240) : undefined,
|
|
10997
11064
|
receipt: receiptPath,
|
|
10998
11065
|
}, cwd);
|
|
@@ -11190,6 +11257,7 @@ async function verifyMissionRunTickPhase(context) {
|
|
|
11190
11257
|
&& (
|
|
11191
11258
|
!currentTick.frozen.verifier
|
|
11192
11259
|
|| context.refereeEach
|
|
11260
|
+
|| (context.refereeIfCheckEdited && tickTouchedCheckDefinition(currentTick.frozen.verifier, currentTick.tickWorktreeBefore, cwd))
|
|
11193
11261
|
|| (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
|
|
11194
11262
|
);
|
|
11195
11263
|
if (shouldRunReferee) {
|
|
@@ -12858,6 +12926,9 @@ function missionCommand(args) {
|
|
|
12858
12926
|
}
|
|
12859
12927
|
|
|
12860
12928
|
module.exports = {
|
|
12929
|
+
receiptLearnedLine,
|
|
12930
|
+
tickTouchedCheckDefinition,
|
|
12931
|
+
claudeRunnerMcpArgs,
|
|
12861
12932
|
crystallizeMemberSkillLesson,
|
|
12862
12933
|
listSiblingWorkspaceMissionHints,
|
|
12863
12934
|
missionProtectedLaneHold,
|
package/lib/member-autoland.js
CHANGED
|
@@ -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))
|
|
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/lib/zen-apply.js
CHANGED
|
@@ -31,6 +31,7 @@ const {
|
|
|
31
31
|
sideCopyLines,
|
|
32
32
|
scheduledCopies,
|
|
33
33
|
runningCopies,
|
|
34
|
+
shortPlace,
|
|
34
35
|
untrackedFiles,
|
|
35
36
|
ABANDONED_DAYS,
|
|
36
37
|
abandonedDaysFromEnv,
|
|
@@ -81,6 +82,13 @@ function identityEnv(root) {
|
|
|
81
82
|
return env;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// Where zen put your copy of a clashing note, said from the repo root so the
|
|
86
|
+
// line fits the pane, or from home when it sits outside the repo.
|
|
87
|
+
function savedPlace(root, file) {
|
|
88
|
+
const rel = path.relative(root, file);
|
|
89
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : shortPlace(file);
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
function archiveStashes(root, tag) {
|
|
85
93
|
const shas = (gitOut(['stash', 'list', '--format=%H'], root) || '').split('\n').filter(Boolean);
|
|
86
94
|
if (!shas.length) return null;
|
|
@@ -444,7 +452,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
444
452
|
for (const p of clashed) {
|
|
445
453
|
const mine = path.join(dir, 'mine', p);
|
|
446
454
|
if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
|
|
447
|
-
else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
|
|
455
|
+
else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, mine)}`);
|
|
448
456
|
}
|
|
449
457
|
for (const p of missed) {
|
|
450
458
|
const mine = path.join(dir, 'mine', p);
|
|
@@ -452,9 +460,9 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
452
460
|
if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
|
|
453
461
|
notes.push(`${p} moved to ${to}, and your edits went with it`);
|
|
454
462
|
} else if (to) {
|
|
455
|
-
notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
463
|
+
notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
|
|
456
464
|
} else {
|
|
457
|
-
notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
465
|
+
notes.push(`${p} no longer fits the new changes; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
|
|
458
466
|
}
|
|
459
467
|
}
|
|
460
468
|
}
|
|
@@ -464,7 +472,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
464
472
|
// A copy that matches the incoming file loses nothing, so say nothing.
|
|
465
473
|
if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
|
|
466
474
|
else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
|
|
467
|
-
else notes.push(`kept the new ${p}; your copy is in ${saved}`);
|
|
475
|
+
else notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, saved)}`);
|
|
468
476
|
} else {
|
|
469
477
|
fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
|
|
470
478
|
fs.copyFileSync(saved, path.join(root, p));
|
package/lib/zen.js
CHANGED
package/package.json
CHANGED