atris 3.82.24 → 3.82.26
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/bin/atris.js +1 -1
- package/commands/autopilot.js +1 -1
- package/commands/clean.js +141 -8
- package/commands/sync-checkout.js +101 -3
- package/commands/task.js +89 -22
- package/commands/worktree.js +28 -2
- package/lib/member-autoland.js +39 -13
- package/lib/task-core.js +2 -1
- package/lib/task-db.js +1 -0
- package/lib/task-receipt.js +8 -3
- package/lib/worktree-core.js +49 -0
- package/package.json +1 -1
package/bin/atris.js
CHANGED
|
@@ -723,7 +723,7 @@ function showHelpAll() {
|
|
|
723
723
|
console.log(' pull - Pull journals + member data from cloud');
|
|
724
724
|
console.log(' push - Push workspace files to cloud');
|
|
725
725
|
console.log(' memory - Your Atris memory for any agent: show, search, remember, notebook; pull/push a laptop copy');
|
|
726
|
-
console.log(' sync-checkout - fetch and fast-forward
|
|
726
|
+
console.log(' sync-checkout - fetch and fast-forward the current branch (--backup saves edits first)');
|
|
727
727
|
console.log(' zen - make this repo calm and current, keeping everything (--check to only look)');
|
|
728
728
|
console.log(' cloud - Delete cloud files not present locally (cloud clean --dry-run|--yes)');
|
|
729
729
|
console.log(' live - Keep a business brain fresh (doctor, pull, watch, push)');
|
package/commands/autopilot.js
CHANGED
|
@@ -190,7 +190,7 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false, problem
|
|
|
190
190
|
? { unhealable: [] }
|
|
191
191
|
: healBrokenMapRefs(cwd, atrisDir, true); // dry-run
|
|
192
192
|
if (unhealable.length > 0 && !skipped.has('fix-map-refs')) {
|
|
193
|
-
const sample = unhealable.slice(0, 3).map(r => `${r.file}:${r.line}`).join(', ');
|
|
193
|
+
const sample = unhealable.slice(0, 3).map(r => (r.line == null ? r.file : `${r.file}:${r.line}`)).join(', ');
|
|
194
194
|
suggestions.push({
|
|
195
195
|
task: `Fix ${unhealable.length} broken reference${unhealable.length > 1 ? 's' : ''} in MAP.md`,
|
|
196
196
|
why: `These file:line references point to code that moved or was deleted: ${sample}. MAP.md is the navigation, it needs to be accurate.`,
|
package/commands/clean.js
CHANGED
|
@@ -46,8 +46,10 @@ function cleanAtris(options = {}) {
|
|
|
46
46
|
results.staleTasks = staleTasks;
|
|
47
47
|
|
|
48
48
|
// 2. Find and HEAL broken MAP.md references
|
|
49
|
-
const { healed, unhealable, replacements } = healBrokenMapRefs(cwd, atrisDir, options.dryRun);
|
|
49
|
+
const { healed, unhealable, replacements, checked = 0, skipped = 0 } = healBrokenMapRefs(cwd, atrisDir, options.dryRun);
|
|
50
50
|
results.healedRefs = healed;
|
|
51
|
+
results.checkedRefs = checked;
|
|
52
|
+
results.skippedRefs = skipped;
|
|
51
53
|
results.healedRefDetails = replacements;
|
|
52
54
|
results.unhealableRefs = unhealable;
|
|
53
55
|
|
|
@@ -108,9 +110,10 @@ function cleanAtris(options = {}) {
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
// Healed refs
|
|
113
|
+
const countText = refCountText(checked, skipped);
|
|
111
114
|
if (healed > 0) {
|
|
112
|
-
const verb = options.dryRun ? '
|
|
113
|
-
console.log(`✓ ${verb} ${healed} MAP.md ${healed === 1 ? 'reference' : 'references'}`);
|
|
115
|
+
const verb = options.dryRun ? 'would heal' : 'healed';
|
|
116
|
+
console.log(`✓ ${verb} ${healed} MAP.md ${healed === 1 ? 'reference' : 'references'} (${countText})`);
|
|
114
117
|
(results.healedRefDetails || []).slice(0, 3).forEach(ref => {
|
|
115
118
|
console.log(` • ${ref.old} -> ${ref.new}${ref.symbol ? ` (${ref.symbol})` : ''}`);
|
|
116
119
|
});
|
|
@@ -121,16 +124,18 @@ function cleanAtris(options = {}) {
|
|
|
121
124
|
|
|
122
125
|
// Unhealable refs
|
|
123
126
|
if (unhealable.length > 0) {
|
|
124
|
-
console.log(`⚠ ${unhealable.length} MAP.md ${unhealable.length === 1 ? 'ref' : 'refs'} couldn't be healed:`);
|
|
127
|
+
console.log(`⚠ ${unhealable.length} MAP.md ${unhealable.length === 1 ? 'ref' : 'refs'} couldn't be healed (${countText}):`);
|
|
125
128
|
unhealable.slice(0, 3).forEach(ref => {
|
|
126
|
-
console.log(` • ${ref
|
|
129
|
+
console.log(` • ${unhealableLine(ref)}`);
|
|
127
130
|
});
|
|
128
131
|
if (unhealable.length > 3) {
|
|
129
132
|
console.log(` ... and ${unhealable.length - 3} more`);
|
|
130
133
|
}
|
|
131
134
|
console.log('');
|
|
135
|
+
} else if (checked === 0) {
|
|
136
|
+
console.log(`○ ${countText}, nothing to validate`);
|
|
132
137
|
} else if (healed === 0) {
|
|
133
|
-
console.log(
|
|
138
|
+
console.log(`✓ all MAP.md refs valid (${countText})`);
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
// Repo-local map scripts
|
|
@@ -274,6 +279,8 @@ function cleanResultPayload(results, options = {}, cwd = process.cwd()) {
|
|
|
274
279
|
items: results.staleTasks,
|
|
275
280
|
},
|
|
276
281
|
map_refs: {
|
|
282
|
+
checked_count: results.checkedRefs || 0,
|
|
283
|
+
skipped_count: results.skippedRefs || 0,
|
|
277
284
|
healed_count: results.healedRefs,
|
|
278
285
|
verb: options.dryRun ? 'would_heal' : 'healed',
|
|
279
286
|
items: results.healedRefDetails || [],
|
|
@@ -375,14 +382,28 @@ function mapNoteFiles(atrisDir) {
|
|
|
375
382
|
return files.filter((file) => { try { return fs.statSync(file).isFile(); } catch { return false; } });
|
|
376
383
|
}
|
|
377
384
|
|
|
385
|
+
function refCountText(checked, skipped) {
|
|
386
|
+
const noun = checked === 1 ? 'ref' : 'refs';
|
|
387
|
+
const other = skipped > 0 ? `, skipped ${skipped} ${skipped === 1 ? 'path' : 'paths'} (other projects or git-ignored)` : '';
|
|
388
|
+
return `checked ${checked} ${noun}${other}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function unhealableLine(ref) {
|
|
392
|
+
// A plain path has no line of its own. Say where in the map it was written.
|
|
393
|
+
if (ref.plain) return `${ref.file}: ${ref.reason} (${ref.map}:${ref.mapLine})`;
|
|
394
|
+
return `${ref.file}:${ref.line}: ${ref.reason}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
378
397
|
function healBrokenMapRefs(cwd, atrisDir, dryRun = false, homeDir = os.homedir()) {
|
|
379
398
|
const mapFile = path.join(atrisDir, 'MAP.md');
|
|
380
|
-
if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [] };
|
|
399
|
+
if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [], checked: 0, skipped: 0 };
|
|
381
400
|
const result = healMapFileRefs(cwd, mapFile, dryRun, homeDir);
|
|
382
401
|
for (const notesFile of mapNoteFiles(atrisDir)) {
|
|
383
402
|
const notes = healMapFileRefs(cwd, notesFile, dryRun, homeDir);
|
|
384
403
|
const map = path.relative(cwd, notesFile).split(path.sep).join('/');
|
|
385
404
|
result.healed += notes.healed;
|
|
405
|
+
result.checked += notes.checked;
|
|
406
|
+
result.skipped += notes.skipped;
|
|
386
407
|
result.unhealable = [...result.unhealable, ...notes.unhealable.map((entry) => ({ ...entry, map }))];
|
|
387
408
|
result.replacements = [...(result.replacements || []), ...notes.replacements.map((entry) => ({ ...entry, map }))];
|
|
388
409
|
}
|
|
@@ -394,6 +415,7 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
|
|
|
394
415
|
let mapContent = fs.readFileSync(mapFile, 'utf8');
|
|
395
416
|
const unhealable = [];
|
|
396
417
|
let healed = 0;
|
|
418
|
+
let checked = 0;
|
|
397
419
|
|
|
398
420
|
// Match both `file.js:123` and `file.js:123-456` with surrounding context
|
|
399
421
|
// [^\S\n] = horizontal whitespace only (no newlines)
|
|
@@ -437,6 +459,7 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
|
|
|
437
459
|
const backtickAfter = match[6] || '';
|
|
438
460
|
const contextPart = match[8] || '';
|
|
439
461
|
|
|
462
|
+
checked++;
|
|
440
463
|
const file = readFileCached(filePath);
|
|
441
464
|
if (!file) {
|
|
442
465
|
unhealable.push({ file: filePath, line: startLine, reason: 'File not found' });
|
|
@@ -497,6 +520,11 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
|
|
|
497
520
|
}
|
|
498
521
|
}
|
|
499
522
|
|
|
523
|
+
// Plain backticked paths with no line number. Report only, never rewrite.
|
|
524
|
+
const plain = checkPlainMapPaths(cwd, mapFile, mapContent);
|
|
525
|
+
checked += plain.checked;
|
|
526
|
+
unhealable.push(...plain.unhealable);
|
|
527
|
+
|
|
500
528
|
// Apply replacements
|
|
501
529
|
if (!dryRun && replacements.length > 0) {
|
|
502
530
|
for (const r of replacements) {
|
|
@@ -505,7 +533,111 @@ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
|
|
|
505
533
|
fs.writeFileSync(mapFile, mapContent);
|
|
506
534
|
}
|
|
507
535
|
|
|
508
|
-
return { healed, unhealable, replacements };
|
|
536
|
+
return { healed, unhealable, replacements, checked, skipped: plain.skipped };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// `folder/file.ext` with a slash and no `:line` suffix. The closing backtick must
|
|
540
|
+
// follow the extension. That keeps `file.js:12` and `file.jsonl` from matching here.
|
|
541
|
+
const PLAIN_PATH_PATTERN = /`((?:\.\.?\/)*[A-Za-z0-9_@.\-]+(?:\/[A-Za-z0-9_@.\-]+)+\.(?:js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml))`/g;
|
|
542
|
+
|
|
543
|
+
// A plain path can be written from the project root, the map's own folder, or any
|
|
544
|
+
// folder between them. Each of those is a base the path is resolved against.
|
|
545
|
+
function plainPathBases(root, mapFile) {
|
|
546
|
+
const bases = [root];
|
|
547
|
+
let dir = path.dirname(path.resolve(mapFile));
|
|
548
|
+
while (dir.startsWith(root + path.sep)) {
|
|
549
|
+
bases.push(dir);
|
|
550
|
+
dir = path.dirname(dir);
|
|
551
|
+
}
|
|
552
|
+
return bases;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function insideRoot(root, target) {
|
|
556
|
+
return target === root || target.startsWith(root + path.sep);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Fenced code blocks hold examples, not navigation. Blank their lines, keeping
|
|
560
|
+
// every line number. The plain-path pass then never reads them.
|
|
561
|
+
function maskFencedBlocks(content) {
|
|
562
|
+
let fence = null;
|
|
563
|
+
return content.split('\n').map((line) => {
|
|
564
|
+
const opener = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
565
|
+
if (fence) {
|
|
566
|
+
if (opener && opener[1][0] === fence.char && opener[1].length >= fence.length && opener[2].trim() === '') fence = null;
|
|
567
|
+
return '';
|
|
568
|
+
}
|
|
569
|
+
if (opener && !(opener[1][0] === '`' && opener[2].includes('`'))) {
|
|
570
|
+
fence = { char: opener[1][0], length: opener[1].length };
|
|
571
|
+
return '';
|
|
572
|
+
}
|
|
573
|
+
return line;
|
|
574
|
+
}).join('\n');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function isDirectory(target) {
|
|
578
|
+
try { return fs.statSync(target).isDirectory(); } catch { return false; }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Runtime files (state folders, logs) are written by the tools and are not in a
|
|
582
|
+
// fresh checkout. Paths the repo's own ignore rules cover are not dead refs.
|
|
583
|
+
function gitIgnoredPaths(cwd, paths) {
|
|
584
|
+
if (paths.length === 0) return new Set();
|
|
585
|
+
const out = spawnSync('git', ['check-ignore', '--no-index', '--stdin'], {
|
|
586
|
+
cwd, input: paths.join('\n'), encoding: 'utf8',
|
|
587
|
+
});
|
|
588
|
+
if (out.status !== 0 && out.status !== 1) return new Set();
|
|
589
|
+
return new Set(String(out.stdout).split('\n').filter(Boolean));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// The first folder of a path, checked in the place it was resolved against. A path
|
|
593
|
+
// that climbs out of its base is judged by its first folder under the project root.
|
|
594
|
+
function firstFolderExists(root, base, target) {
|
|
595
|
+
let anchor = base;
|
|
596
|
+
let from = path.relative(base, target);
|
|
597
|
+
if (from === '..' || from.startsWith('..' + path.sep)) {
|
|
598
|
+
anchor = root;
|
|
599
|
+
from = path.relative(root, target);
|
|
600
|
+
}
|
|
601
|
+
const parts = from.split(path.sep);
|
|
602
|
+
return parts.length > 1 && isDirectory(path.join(anchor, parts[0]));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function checkPlainMapPaths(cwd, mapFile, content) {
|
|
606
|
+
const root = path.resolve(cwd);
|
|
607
|
+
const map = path.relative(root, path.resolve(mapFile)).split(path.sep).join('/');
|
|
608
|
+
const bases = plainPathBases(root, mapFile);
|
|
609
|
+
const scanned = maskFencedBlocks(content);
|
|
610
|
+
const seen = new Set();
|
|
611
|
+
const missing = [];
|
|
612
|
+
let checked = 0;
|
|
613
|
+
let skipped = 0;
|
|
614
|
+
const pattern = new RegExp(PLAIN_PATH_PATTERN.source, 'g');
|
|
615
|
+
let match;
|
|
616
|
+
while ((match = pattern.exec(scanned)) !== null) {
|
|
617
|
+
const rel = match[1];
|
|
618
|
+
if (seen.has(rel)) continue;
|
|
619
|
+
seen.add(rel);
|
|
620
|
+
// Anything that resolves outside the project belongs to another project.
|
|
621
|
+
const candidates = bases
|
|
622
|
+
.map((base) => ({ base, target: path.resolve(base, rel) }))
|
|
623
|
+
.filter((candidate) => insideRoot(root, candidate.target));
|
|
624
|
+
if (candidates.some((candidate) => fs.existsSync(candidate.target))) { checked++; continue; }
|
|
625
|
+
// A path whose first folder is in none of its bases belongs to another project.
|
|
626
|
+
const here = candidates
|
|
627
|
+
.filter((candidate) => firstFolderExists(root, candidate.base, candidate.target))
|
|
628
|
+
.map((candidate) => path.relative(root, candidate.target).split(path.sep).join('/'));
|
|
629
|
+
if (here.length === 0) { skipped++; continue; }
|
|
630
|
+
const mapLine = scanned.slice(0, match.index).split('\n').length;
|
|
631
|
+
missing.push({ rel, mapLine, here });
|
|
632
|
+
}
|
|
633
|
+
const ignored = gitIgnoredPaths(root, missing.flatMap((entry) => entry.here));
|
|
634
|
+
const unhealable = [];
|
|
635
|
+
for (const { rel, mapLine, here } of missing) {
|
|
636
|
+
if (here.every((candidate) => ignored.has(candidate))) { skipped++; continue; }
|
|
637
|
+
checked++;
|
|
638
|
+
unhealable.push({ file: rel, line: null, mapLine, map, reason: 'missing file', plain: true });
|
|
639
|
+
}
|
|
640
|
+
return { checked, skipped, unhealable };
|
|
509
641
|
}
|
|
510
642
|
|
|
511
643
|
/**
|
|
@@ -851,6 +983,7 @@ module.exports = {
|
|
|
851
983
|
cleanResultPayload,
|
|
852
984
|
findStaleTasks,
|
|
853
985
|
healBrokenMapRefs,
|
|
986
|
+
unhealableLine,
|
|
854
987
|
findStalePages,
|
|
855
988
|
checkPageStaleness
|
|
856
989
|
};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
3
7
|
const {
|
|
4
8
|
runGit,
|
|
5
9
|
trackedTreeIsClean,
|
|
@@ -10,19 +14,80 @@ function refuse(message) {
|
|
|
10
14
|
return 1;
|
|
11
15
|
}
|
|
12
16
|
|
|
17
|
+
function pad(value) {
|
|
18
|
+
return String(value).padStart(2, '0');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2026-11-12-134655, local time, so a backup sorts and reads like a date.
|
|
22
|
+
function backupStamp(date = new Date()) {
|
|
23
|
+
return [
|
|
24
|
+
date.getFullYear(),
|
|
25
|
+
pad(date.getMonth() + 1),
|
|
26
|
+
pad(date.getDate()),
|
|
27
|
+
].join('-') + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Saves the checkout's tracked edits as a patch under ~/.atris/backups/<repo>/.
|
|
31
|
+
// Untracked files are only listed (in a sibling .untracked.txt), never copied.
|
|
32
|
+
// Returns { patchPath, untrackedPath } or null when nothing could be saved.
|
|
33
|
+
function writeBackup(cwd) {
|
|
34
|
+
const topResult = runGit(['rev-parse', '--show-toplevel'], cwd);
|
|
35
|
+
const top = topResult.status === 0 ? topResult.stdout.trim() : cwd;
|
|
36
|
+
const repoName = path.basename(top) || 'checkout';
|
|
37
|
+
|
|
38
|
+
const dir = path.join(os.homedir(), '.atris', 'backups', repoName);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const stamp = backupStamp();
|
|
42
|
+
let patchPath = null;
|
|
43
|
+
for (let attempt = 0; attempt < 100 && !patchPath; attempt += 1) {
|
|
44
|
+
const candidate = path.join(dir, `${stamp}${attempt === 0 ? '' : `-${attempt}`}.patch`);
|
|
45
|
+
if (!fs.existsSync(candidate)) patchPath = candidate;
|
|
46
|
+
}
|
|
47
|
+
if (!patchPath) return null;
|
|
48
|
+
|
|
49
|
+
// Diff against HEAD so staged and unstaged edits land in one patch. Git
|
|
50
|
+
// writes the file itself, so the bytes are exact even for odd encodings.
|
|
51
|
+
const diffResult = runGit(
|
|
52
|
+
['diff', 'HEAD', '--binary', '--no-color', '--no-ext-diff', `--output=${patchPath}`],
|
|
53
|
+
cwd
|
|
54
|
+
);
|
|
55
|
+
const written = diffResult.status === 0 && fs.existsSync(patchPath) && fs.statSync(patchPath).size > 0;
|
|
56
|
+
if (!written) {
|
|
57
|
+
fs.rmSync(patchPath, { force: true });
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const untrackedResult = runGit(['ls-files', '--others', '--exclude-standard'], top);
|
|
62
|
+
const untracked = untrackedResult.status === 0
|
|
63
|
+
? untrackedResult.stdout.split('\n').filter(Boolean)
|
|
64
|
+
: [];
|
|
65
|
+
let untrackedPath = null;
|
|
66
|
+
if (untracked.length > 0) {
|
|
67
|
+
untrackedPath = patchPath.replace(/\.patch$/, '.untracked.txt');
|
|
68
|
+
fs.writeFileSync(untrackedPath, `${untracked.join('\n')}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { patchPath, untrackedPath };
|
|
72
|
+
}
|
|
73
|
+
|
|
13
74
|
function syncCheckoutCommand(args = [], cwd = process.cwd()) {
|
|
14
75
|
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
15
|
-
console.log('usage: atris sync-checkout.');
|
|
76
|
+
console.log('usage: atris sync-checkout [--backup].');
|
|
16
77
|
return 0;
|
|
17
78
|
}
|
|
79
|
+
const backup = args.includes('--backup');
|
|
18
80
|
|
|
19
81
|
const branchResult = runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);
|
|
20
82
|
if (branchResult.status !== 0) {
|
|
21
83
|
return refuse('this checkout is not on a git branch; sync-checkout refused to touch it.');
|
|
22
84
|
}
|
|
23
85
|
|
|
24
|
-
|
|
25
|
-
|
|
86
|
+
const dirty = !trackedTreeIsClean(cwd);
|
|
87
|
+
if (dirty && !backup) {
|
|
88
|
+
console.log('this checkout has tracked changes; sync-checkout refused to touch it.');
|
|
89
|
+
console.log('run: atris sync-checkout --backup (saves your edits as a patch, then syncs)');
|
|
90
|
+
return 1;
|
|
26
91
|
}
|
|
27
92
|
|
|
28
93
|
const upstreamResult = runGit(
|
|
@@ -39,6 +104,39 @@ function syncCheckoutCommand(args = [], cwd = process.cwd()) {
|
|
|
39
104
|
return refuse('this checkout could not sync because git fetch failed.');
|
|
40
105
|
}
|
|
41
106
|
|
|
107
|
+
if (dirty) {
|
|
108
|
+
// Check the sync can succeed before touching anything, so a sync that
|
|
109
|
+
// was never going to work does not cost anyone their edits.
|
|
110
|
+
const canFastForward = runGit(['merge-base', '--is-ancestor', 'HEAD', '@{upstream}'], cwd);
|
|
111
|
+
if (canFastForward.status !== 0) {
|
|
112
|
+
return refuse('this checkout could not fast-forward; sync-checkout refused to change it.');
|
|
113
|
+
}
|
|
114
|
+
const behindResult = runGit(['rev-list', '--count', 'HEAD..@{upstream}'], cwd);
|
|
115
|
+
if (behindResult.status === 0 && behindResult.stdout.trim() === '0') {
|
|
116
|
+
console.log(`this checkout is up to date with ${upstream}; your edits were left in place.`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let saved;
|
|
121
|
+
try {
|
|
122
|
+
saved = writeBackup(cwd);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
saved = null;
|
|
125
|
+
}
|
|
126
|
+
if (!saved) {
|
|
127
|
+
return refuse('could not save your edits as a patch; sync-checkout refused to touch it.');
|
|
128
|
+
}
|
|
129
|
+
const resetResult = runGit(['reset', '--hard', '--quiet', 'HEAD'], cwd);
|
|
130
|
+
if (resetResult.status !== 0) {
|
|
131
|
+
return refuse(`could not set your edits aside; they are saved at ${saved.patchPath}`);
|
|
132
|
+
}
|
|
133
|
+
console.log(`saved your edits to ${saved.patchPath}`);
|
|
134
|
+
if (saved.untrackedPath) {
|
|
135
|
+
console.log(`untracked files were left in place; their names are in ${saved.untrackedPath}`);
|
|
136
|
+
}
|
|
137
|
+
console.log(`to restore them: git apply ${saved.patchPath}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
42
140
|
const mergeResult = runGit(['merge', '--ff-only', '@{upstream}'], cwd);
|
|
43
141
|
if (mergeResult.status !== 0) {
|
|
44
142
|
return refuse('this checkout could not fast-forward; sync-checkout refused to change it.');
|
package/commands/task.js
CHANGED
|
@@ -9000,6 +9000,38 @@ function appendRelabelArchivedJournalReceipt(workspaceRoot, { actor, count, ids
|
|
|
9000
9000
|
return logPath;
|
|
9001
9001
|
}
|
|
9002
9002
|
|
|
9003
|
+
// --work-root <path>, or ATRIS_WORK_ROOT only when the flag is absent. Nothing named gives
|
|
9004
|
+
// undefined. The job then records the main folder's commit as before. A flag that is present
|
|
9005
|
+
// but empty is an error, never a quiet fall back to the env var. A relative path resolves
|
|
9006
|
+
// from the current folder. The folder must be a real checkout with a commit, because that
|
|
9007
|
+
// commit is what the job records.
|
|
9008
|
+
function readyWorkRoot(args) {
|
|
9009
|
+
const at = args.indexOf('--work-root');
|
|
9010
|
+
let named;
|
|
9011
|
+
if (at !== -1) {
|
|
9012
|
+
named = String(args[at + 1] === undefined ? '' : args[at + 1]).trim();
|
|
9013
|
+
if (!named) {
|
|
9014
|
+
console.error('work root is empty');
|
|
9015
|
+
process.exit(1);
|
|
9016
|
+
}
|
|
9017
|
+
} else {
|
|
9018
|
+
named = String(process.env.ATRIS_WORK_ROOT || '').trim();
|
|
9019
|
+
}
|
|
9020
|
+
if (!named) return undefined;
|
|
9021
|
+
const resolved = path.resolve(named);
|
|
9022
|
+
let isDir = false;
|
|
9023
|
+
try { isDir = fs.statSync(resolved).isDirectory(); } catch (_) { isDir = false; }
|
|
9024
|
+
if (!isDir) {
|
|
9025
|
+
console.error(`work root not found: ${resolved}`);
|
|
9026
|
+
process.exit(1);
|
|
9027
|
+
}
|
|
9028
|
+
if (!getTaskDb().workspaceHead(resolved)) {
|
|
9029
|
+
console.error(`work root has no git commit: ${resolved}`);
|
|
9030
|
+
process.exit(1);
|
|
9031
|
+
}
|
|
9032
|
+
return resolved;
|
|
9033
|
+
}
|
|
9034
|
+
|
|
9003
9035
|
function cmdReady(args) {
|
|
9004
9036
|
const pos = positional(args);
|
|
9005
9037
|
const id = pos[0];
|
|
@@ -9007,9 +9039,12 @@ function cmdReady(args) {
|
|
|
9007
9039
|
const verifyFlag = flag(args, '--verify');
|
|
9008
9040
|
const resultFlag = textFlag(args, ['--result']);
|
|
9009
9041
|
if (!id || (!proofFlag && !verifyFlag && !resultFlag)) {
|
|
9010
|
-
console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>"');
|
|
9042
|
+
console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>" [--work-root <path>] (the copy you built in; ATRIS_WORK_ROOT is the fallback; a relative path resolves from the current folder)');
|
|
9011
9043
|
process.exit(2);
|
|
9012
9044
|
}
|
|
9045
|
+
// The copy the builder actually built in. Its commit is the one the job records.
|
|
9046
|
+
// Without it the job records the main folder's commit, as before.
|
|
9047
|
+
const workRoot = readyWorkRoot(args);
|
|
9013
9048
|
// Two ways to prove: --proof "<note>" (claimed, pattern-checked) or
|
|
9014
9049
|
// --verify "<command>" which actually RUNS the command and gates ready on exit 0.
|
|
9015
9050
|
// If --proof names npm test, node --test, or git diff --check, this process
|
|
@@ -9041,12 +9076,54 @@ function cmdReady(args) {
|
|
|
9041
9076
|
proof = proof.trim() ? `${proof.trim()} ${attested}` : attested;
|
|
9042
9077
|
}
|
|
9043
9078
|
const verifyAutoCertifyAllowed = !usedVerify || isAutoCertifyVerifyCommandAllowed(verifyFlag);
|
|
9079
|
+
const landing = landingFlags(args);
|
|
9080
|
+
// What this process can refuse from the flags and the task alone is refused before the verifier
|
|
9081
|
+
// runs. Wording or a name the gate would turn away never costs a test run. The verifier's own
|
|
9082
|
+
// wording, the actor, the task lookup and the landing sentence only need the flags and the task
|
|
9083
|
+
// row. Without --verify nothing slow comes first. The actor, task and landing checks then keep
|
|
9084
|
+
// their place after the proof checks below.
|
|
9085
|
+
let readyTarget = null;
|
|
9086
|
+
const resolveReadyTarget = () => {
|
|
9087
|
+
if (readyTarget) return readyTarget;
|
|
9088
|
+
guardExplicitActor('atris task ready', flag(args, '--as'));
|
|
9089
|
+
const taskDb = getTaskDb();
|
|
9090
|
+
const db = taskDb.open();
|
|
9091
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task ready');
|
|
9092
|
+
const beforeTask = taskDetail(taskDb, db, taskId);
|
|
9093
|
+
requireExplicitLandingDayOnePm('atris task ready', landing, beforeTask && beforeTask.title);
|
|
9094
|
+
readyTarget = { taskDb, db, taskId, beforeTask };
|
|
9095
|
+
return readyTarget;
|
|
9096
|
+
};
|
|
9097
|
+
// The start of the proof written once the verifier passes. It is built from the flags alone. A
|
|
9098
|
+
// proof that names a command the verifier did not run is refused before the verifier runs. The
|
|
9099
|
+
// command's output and the receipt path follow it. Then the same check runs again on the whole
|
|
9100
|
+
// proof.
|
|
9101
|
+
const verifiedLead = usedVerify
|
|
9102
|
+
? `[verified] \`${verifyFlag}\` passed (exit 0)${proof.trim() ? `, ${proof.trim()}` : ''}`
|
|
9103
|
+
: '';
|
|
9104
|
+
if (usedVerify) {
|
|
9105
|
+
if (!hasFlag(args, '--no-falsify-check')) {
|
|
9106
|
+
const { classifyVerifier } = require('../lib/verifier-quality');
|
|
9107
|
+
const quality = classifyVerifier(verifyFlag);
|
|
9108
|
+
if (!quality.ok) {
|
|
9109
|
+
console.error(`atris task ready: weak verifier, ${quality.reason}`);
|
|
9110
|
+
console.error(` command: ${verifyFlag}`);
|
|
9111
|
+
console.error(' allowlisted examples: node --test, npm test, git diff --check, node --check, rg <symbol>');
|
|
9112
|
+
console.error(' or pass --no-falsify-check to accept anyway.');
|
|
9113
|
+
process.exit(1);
|
|
9114
|
+
}
|
|
9115
|
+
}
|
|
9116
|
+
requireRanNamedProofCommand('atris task ready', verifiedLead, usedVerify);
|
|
9117
|
+
resolveReadyTarget();
|
|
9118
|
+
}
|
|
9044
9119
|
if (usedVerify) {
|
|
9045
9120
|
// Run the verifier once and write a receipt (pass or fail) so the review
|
|
9046
9121
|
// gate in lib/receipt-evidence.js can validate the exact path named in
|
|
9047
9122
|
// the proof, not just trust the prose.
|
|
9048
9123
|
const { writeTaskReceipt } = require('../lib/task-receipt');
|
|
9049
|
-
|
|
9124
|
+
// The check runs in the copy the work was built in. Its receipt still lands under the
|
|
9125
|
+
// folder ready ran from, because that is where a reviewer looks for the path in the proof.
|
|
9126
|
+
const receipt = writeTaskReceipt({ taskId: id, command: verifyFlag, root: workRoot || process.cwd(), receiptRoot: process.cwd() });
|
|
9050
9127
|
if (!receipt.passed) {
|
|
9051
9128
|
const detail = receipt.exit != null ? ` (exit ${receipt.exit})` : (receipt.signal ? ` (signal ${receipt.signal})` : '');
|
|
9052
9129
|
console.error(`atris task ready: verifier failed${detail}: ${verifyFlag}`);
|
|
@@ -9060,15 +9137,6 @@ function cmdReady(args) {
|
|
|
9060
9137
|
// this codebase fails there, and one that passes anywhere passes there too.
|
|
9061
9138
|
if (!hasFlag(args, '--no-falsify-check')) {
|
|
9062
9139
|
const { probeVerifierCanFail } = require('../lib/falsifier-probe');
|
|
9063
|
-
const { classifyVerifier } = require('../lib/verifier-quality');
|
|
9064
|
-
const quality = classifyVerifier(verifyFlag);
|
|
9065
|
-
if (!quality.ok) {
|
|
9066
|
-
console.error(`atris task ready: weak verifier, ${quality.reason}`);
|
|
9067
|
-
console.error(` command: ${verifyFlag}`);
|
|
9068
|
-
console.error(' allowlisted examples: node --test, npm test, git diff --check, node --check, rg <symbol>');
|
|
9069
|
-
console.error(' or pass --no-falsify-check to accept anyway.');
|
|
9070
|
-
process.exit(1);
|
|
9071
|
-
}
|
|
9072
9140
|
const probe = probeVerifierCanFail({ command: verifyFlag });
|
|
9073
9141
|
if (probe.probed && probe.canFail === false) {
|
|
9074
9142
|
console.error(`atris task ready: this check cannot fail, ${probe.reason}`);
|
|
@@ -9078,8 +9146,7 @@ function cmdReady(args) {
|
|
|
9078
9146
|
}
|
|
9079
9147
|
if (!probe.probed && !wantsJson(args)) console.log(` ${probe.reason}`);
|
|
9080
9148
|
}
|
|
9081
|
-
|
|
9082
|
-
proof = `[verified] \`${verifyFlag}\` passed (exit 0)${base ? `, ${base}` : ''}${receipt.output ? `\n${receipt.output}` : ''}\nReceipt: ${receipt.receiptPath}`;
|
|
9149
|
+
proof = `${verifiedLead}${receipt.output ? `\n${receipt.output}` : ''}\nReceipt: ${receipt.receiptPath}`;
|
|
9083
9150
|
if (!wantsJson(args)) console.log(`✓ verified: \`${verifyFlag}\` exited 0 (receipt ${receipt.receiptPath})`);
|
|
9084
9151
|
}
|
|
9085
9152
|
if (!proof) {
|
|
@@ -9090,9 +9157,7 @@ function cmdReady(args) {
|
|
|
9090
9157
|
requireRanNamedProofCommand('atris task ready', proof, usedVerify);
|
|
9091
9158
|
const lesson = flag(args, '--lesson') || '';
|
|
9092
9159
|
const nextTaskInput = normalizeReviewNextTaskInput(typeof flag(args, '--next') === 'string' ? flag(args, '--next') : '');
|
|
9093
|
-
const landing = landingFlags(args);
|
|
9094
9160
|
const merge = readyMergeFlags(args);
|
|
9095
|
-
guardExplicitActor('atris task ready', flag(args, '--as'));
|
|
9096
9161
|
const actor = String(flag(args, '--as') || DEFAULT_OWNER);
|
|
9097
9162
|
// The others who built the work with --as, by name, comma apart: a flight that ran for a member
|
|
9098
9163
|
// hands in under the member's name and names the engines that ran here. Only a hand-in uses it.
|
|
@@ -9109,18 +9174,19 @@ function cmdReady(args) {
|
|
|
9109
9174
|
files: textFlag(args, ['--files']),
|
|
9110
9175
|
commands: textFlag(args, ['--commands', '--command']),
|
|
9111
9176
|
};
|
|
9112
|
-
const taskDb =
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9177
|
+
const { taskDb, db, taskId } = resolveReadyTarget();
|
|
9178
|
+
// The verifier may have run for a while. The task is read again after it. Without one, nothing
|
|
9179
|
+
// elapsed and the read from the checks above stands. The landing sentence is checked against the
|
|
9180
|
+
// refreshed title too, since a verifier can rename the task.
|
|
9181
|
+
const beforeTask = usedVerify ? taskDetail(taskDb, db, taskId) : readyTarget.beforeTask;
|
|
9182
|
+
if (usedVerify) requireExplicitLandingDayOnePm('atris task ready', landing, beforeTask && beforeTask.title);
|
|
9117
9183
|
const missionXpIssue = missionXpEndToEndProofIssue(beforeTask, proof, taskDb.workspaceRoot());
|
|
9118
9184
|
if (missionXpIssue) {
|
|
9119
9185
|
failTask('atris task ready', MISSION_XP_END_TO_END_REASON, missionXpIssue);
|
|
9120
9186
|
}
|
|
9121
9187
|
const readyPolicyTask = {
|
|
9122
9188
|
...beforeTask,
|
|
9123
|
-
workspace_root: process.cwd(),
|
|
9189
|
+
workspace_root: workRoot || process.cwd(),
|
|
9124
9190
|
metadata: {
|
|
9125
9191
|
...(beforeTask && beforeTask.metadata || {}),
|
|
9126
9192
|
...(resultFields.files ? { changed_files: resultFields.files } : {}),
|
|
@@ -9145,6 +9211,7 @@ function cmdReady(args) {
|
|
|
9145
9211
|
merge,
|
|
9146
9212
|
result: resultSentence,
|
|
9147
9213
|
builtWith,
|
|
9214
|
+
workRoot,
|
|
9148
9215
|
});
|
|
9149
9216
|
if (!result.ready) {
|
|
9150
9217
|
console.error(`ready failed: ${result.reason}`);
|
|
@@ -12235,7 +12302,7 @@ async function run(args) {
|
|
|
12235
12302
|
// `task ready --help` is a help request, not a run. Ready mutates
|
|
12236
12303
|
// review state, so never list or ready just to show usage.
|
|
12237
12304
|
if (sub === 'ready' && argsWantHelp(raw.slice(1))) {
|
|
12238
|
-
console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>"');
|
|
12305
|
+
console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>" [--work-root <path>] (the copy you built in; ATRIS_WORK_ROOT is the fallback; a relative path resolves from the current folder)');
|
|
12239
12306
|
return;
|
|
12240
12307
|
}
|
|
12241
12308
|
// `task step --help` is a help request, not a run. Step mutates
|
package/commands/worktree.js
CHANGED
|
@@ -60,6 +60,7 @@ const {
|
|
|
60
60
|
repoRoot,
|
|
61
61
|
restoreRegeneratedAdapterChurn,
|
|
62
62
|
runCommand,
|
|
63
|
+
runShipNets,
|
|
63
64
|
runGit,
|
|
64
65
|
runShipHealthCheck,
|
|
65
66
|
scheduleShippedCopyRemoval,
|
|
@@ -186,18 +187,37 @@ function startWorktree(args) {
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
function shipHelp() {
|
|
189
|
-
console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
190
|
+
console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local] [--skip-nets]');
|
|
190
191
|
console.log('');
|
|
191
192
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
192
193
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
193
194
|
console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
|
|
194
195
|
console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
|
|
195
196
|
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
197
|
+
console.log(' --skip-nets skip the repo hygiene tests that run before --verify (or set ATRIS_SHIP_SKIP_NETS=1)');
|
|
198
|
+
console.log(' nets run first: repo-hygiene, no-hardcoded-home-paths and map-refs-accurate, for repos that have them; a failure stops the ship before the push');
|
|
196
199
|
console.log(' --keep-copy keep this build copy; by default it is removed once the work lands or is pushed');
|
|
197
200
|
console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
|
|
198
201
|
console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
// A pull request GitHub left open for its checks goes on the list the autoland
|
|
205
|
+
// tick watches. A flaky merge-line run that drops it is then queued again
|
|
206
|
+
// without a person. Failing to write the list never fails the ship.
|
|
207
|
+
function recordPendingShip(root, { branch, targetRef, number }) {
|
|
208
|
+
try {
|
|
209
|
+
const pushed = runGit(['rev-parse', 'HEAD'], { cwd: root }).stdout.trim();
|
|
210
|
+
const autoland = require('../lib/member-autoland');
|
|
211
|
+
autoland.recordQueuedPullRequest(root, { branch, base: baseBranchName(targetRef), number, pushed });
|
|
212
|
+
// The tick skips the whole list while self-landing is off.
|
|
213
|
+
console.log(autoland.policyOn(root)
|
|
214
|
+
? `queue: recorded pull request ${number} for the tick's catch-up`
|
|
215
|
+
: `queue: recorded pull request ${number}, catch-up is off until self-landing is on`);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.log(`queue: not recorded (${String(error.message || error).split('\n')[0]})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
201
221
|
function shipWorktree(args) {
|
|
202
222
|
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
203
223
|
shipHelp();
|
|
@@ -293,6 +313,9 @@ function shipWorktree(args) {
|
|
|
293
313
|
refreshMovedMapRefs(root, args);
|
|
294
314
|
}
|
|
295
315
|
|
|
316
|
+
const nets = runShipNets(root, { verify, skip: hasFlag(args, '--skip-nets'), dryRun });
|
|
317
|
+
if (!nets.ok) return 1;
|
|
318
|
+
|
|
296
319
|
if (verify) {
|
|
297
320
|
const beforeVerify = dryRun ? [] : statusSnapshot(root);
|
|
298
321
|
console.log(`verify: ${verify}`);
|
|
@@ -390,6 +413,7 @@ function shipWorktree(args) {
|
|
|
390
413
|
}
|
|
391
414
|
if (state.state === 'OPEN' && !state.mergedAt) {
|
|
392
415
|
console.log('merge: pending; kept the source branch while required checks finish');
|
|
416
|
+
recordPendingShip(root, { branch, targetRef, number: state.number });
|
|
393
417
|
finishShippedCopy({ root, primary, branch, keep: keepCopy });
|
|
394
418
|
return 0;
|
|
395
419
|
}
|
|
@@ -619,6 +643,7 @@ function guide() {
|
|
|
619
643
|
console.log('4. Ship only from the isolated worktree:');
|
|
620
644
|
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" --merge [--target <ref>]');
|
|
621
645
|
console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
|
|
646
|
+
console.log(' ship runs the repo hygiene nets first (repo-hygiene, no-hardcoded-home-paths, map-refs-accurate); --skip-nets or ATRIS_SHIP_SKIP_NETS=1 skips them');
|
|
622
647
|
console.log(' recommended verify: npm run test:fast && node --test <focused files>');
|
|
623
648
|
console.log('');
|
|
624
649
|
console.log('5. Cleanup is part of creation, shipping, and the sweep; run it directly any time:');
|
|
@@ -645,10 +670,11 @@ function help() {
|
|
|
645
670
|
console.log(' --force start even when another recent flight shares a task word');
|
|
646
671
|
console.log(' --former-task the task\'s older name; a branch left unfinished under it is named, never resumed');
|
|
647
672
|
console.log(' --no-deps do not clone node_modules from a matching install');
|
|
648
|
-
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
673
|
+
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local] [--skip-nets]');
|
|
649
674
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
650
675
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
651
676
|
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
677
|
+
console.log(' --skip-nets skip the repo hygiene tests that run before --verify; they run first by default');
|
|
652
678
|
console.log(' --keep-copy keep this build copy after it ships');
|
|
653
679
|
console.log(' recommended verify: npm run test:fast && node --test <focused files>');
|
|
654
680
|
console.log(' atris worktree status');
|
package/lib/member-autoland.js
CHANGED
|
@@ -384,14 +384,36 @@ function writeQueue(file, entries) {
|
|
|
384
384
|
fs.renameSync(tmp, file);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
387
|
+
// Put a pull request on the list the sweep watches, keyed by its branch. Autoland
|
|
388
|
+
// does this when it queues a member's change; `atris worktree ship --merge`
|
|
389
|
+
// does it when GitHub leaves the pull request open for its checks. A flaky
|
|
390
|
+
// merge-line run that drops it is then caught up by the sweep too. A later
|
|
391
|
+
// record for the same branch keeps its catch-up count and retry mark while
|
|
392
|
+
// the pushed commit and pull request stay the same. A new commit starts them
|
|
393
|
+
// over. A new pull request number starts a new entry: the jobs and mission
|
|
394
|
+
// of the earlier pull request do not carry over, or its merge would close
|
|
395
|
+
// jobs it never carried. fresh: true replaces the entry outright. Throws when
|
|
396
|
+
// the details cannot be recorded; the caller decides whether that matters.
|
|
397
|
+
function recordQueuedPullRequest(cwd, fields, { fresh = false } = {}) {
|
|
388
398
|
const file = queueFile(cwd);
|
|
389
|
-
if (!file)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
399
|
+
if (!file) throw new Error('not inside a git checkout');
|
|
400
|
+
const { branch, base, number, pushed } = fields || {};
|
|
401
|
+
const valid = typeof branch === 'string' && branch && typeof base === 'string' && base
|
|
402
|
+
&& Number.isInteger(number) && number > 0 && /^[0-9a-f]{40,64}$/.test(String(pushed || ''));
|
|
403
|
+
if (!valid) throw new Error('the branch, base, pull request number, or pushed commit could not be read');
|
|
404
|
+
const entries = readQueue(file);
|
|
405
|
+
const found = fresh ? null : entries[branch];
|
|
406
|
+
const prior = found && typeof found === 'object' && found.number === number ? found : null;
|
|
407
|
+
const same = Boolean(prior) && prior.pushed === pushed;
|
|
408
|
+
const entry = { ...(prior || {}), ...fields };
|
|
409
|
+
if (!fresh) {
|
|
410
|
+
entry.requeues = same ? Number(prior.requeues) || 0 : 0;
|
|
411
|
+
entry.reran = same ? prior.reran || false : false;
|
|
412
|
+
}
|
|
413
|
+
entry.queued_at = new Date().toISOString();
|
|
414
|
+
entries[branch] = entry;
|
|
415
|
+
writeQueue(file, entries);
|
|
416
|
+
return entry;
|
|
395
417
|
}
|
|
396
418
|
|
|
397
419
|
function held(gate, line) {
|
|
@@ -566,12 +588,14 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
|
|
|
566
588
|
|
|
567
589
|
const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', pushSha], cwd);
|
|
568
590
|
if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
591
|
+
try {
|
|
592
|
+
recordQueuedPullRequest(cwd, {
|
|
593
|
+
branch, base, number, pushed: pushSha, mission_id: mission.id || null,
|
|
594
|
+
task_ids: [...new Set([...taskIds, ...(Array.isArray(closeTaskIds) ? closeTaskIds : [])
|
|
595
|
+
.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())])],
|
|
596
|
+
requeues: 0,
|
|
597
|
+
}, { fresh: true });
|
|
598
|
+
} catch {}
|
|
575
599
|
const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
|
|
576
600
|
: landing.mapMerged ? ' (map lines from both sides kept)' : '';
|
|
577
601
|
return {
|
|
@@ -931,6 +955,8 @@ module.exports = {
|
|
|
931
955
|
sweepAfterMissionRun,
|
|
932
956
|
landingDestination,
|
|
933
957
|
requeueDroppedAutolands,
|
|
958
|
+
recordQueuedPullRequest,
|
|
959
|
+
policyOn,
|
|
934
960
|
codeStatus,
|
|
935
961
|
gitCodeState,
|
|
936
962
|
};
|
package/lib/task-core.js
CHANGED
|
@@ -125,8 +125,9 @@ atris task - durable local task state (SQLite, gitignored)
|
|
|
125
125
|
atris task continue-work <id> Create/reuse a certified Review follow-up task
|
|
126
126
|
atris task say <id> "<message>" Add context to a task
|
|
127
127
|
atris task chat <id> "<message>" [--goal "..."] Refine a task chat + working goal
|
|
128
|
-
atris task ready <id> --proof "..." --result "<sentence>"
|
|
128
|
+
atris task ready <id> --proof "..." --result "<sentence>" [--work-root <path>]
|
|
129
129
|
Agent proof ready; native goal can complete
|
|
130
|
+
--work-root names the copy you built in and the job records that copy's commit; ATRIS_WORK_ROOT is the fallback and a relative path resolves from the current folder
|
|
130
131
|
atris task ready <id> --verify "<cmd>" --result "<sentence>"
|
|
131
132
|
Run <cmd>; only ready if it exits 0 (executed proof)
|
|
132
133
|
Writes atris/runs/ receipt (pass or fail); that local pass is enough proof
|
package/lib/task-db.js
CHANGED
package/lib/task-receipt.js
CHANGED
|
@@ -54,7 +54,10 @@ function verifyTimeoutMs() {
|
|
|
54
54
|
// Runs `command` for `taskId`, writes atris/runs/<date>-task-<id>-<time>.json,
|
|
55
55
|
// and returns { ok, passed, receiptPath, exit, signal, output }. `ok` mirrors
|
|
56
56
|
// `passed`, kept as a separate field so callers reading either name work.
|
|
57
|
-
|
|
57
|
+
// The command runs in `root`. The receipt file goes under `receiptRoot`, which
|
|
58
|
+
// is `root` unless the caller built in another copy and wants the receipt left
|
|
59
|
+
// where a reviewer looks for it.
|
|
60
|
+
function writeTaskReceipt({ taskId, command, root = process.cwd(), receiptRoot = root, runner } = {}) {
|
|
58
61
|
const cmd = String(command || '').trim();
|
|
59
62
|
if (!taskId) return { ok: false, passed: false, reason: 'task_id_required' };
|
|
60
63
|
if (!cmd) return { ok: false, passed: false, reason: 'verify_command_required' };
|
|
@@ -83,7 +86,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
83
86
|
: '';
|
|
84
87
|
const output = tailText(`${result.stdout || ''}${result.stderr || ''}${diagnostic}`);
|
|
85
88
|
const passed = !result.error && result.status === 0;
|
|
86
|
-
const dir = runsDir(
|
|
89
|
+
const dir = runsDir(receiptRoot);
|
|
87
90
|
fs.mkdirSync(dir, { recursive: true });
|
|
88
91
|
const now = new Date();
|
|
89
92
|
// Local calendar day for the file name; toISOString() is the UTC day,
|
|
@@ -91,13 +94,15 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
91
94
|
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
92
95
|
const safeTime = now.toISOString().replace(/[:.]/g, '-');
|
|
93
96
|
const receiptFile = path.join(dir, `${date}-task-${slugifyTaskId(taskId)}-${safeTime}.json`);
|
|
94
|
-
const receiptPath = path.relative(
|
|
97
|
+
const receiptPath = path.relative(receiptRoot, receiptFile);
|
|
95
98
|
const receipt = {
|
|
96
99
|
schema: 'atris.task_receipt.v1',
|
|
97
100
|
task_id: String(taskId),
|
|
98
101
|
command: cmd,
|
|
99
102
|
at: now.toISOString(),
|
|
100
103
|
commit: gitCommitHash(root),
|
|
104
|
+
work_root: path.resolve(root),
|
|
105
|
+
receipt_root: path.resolve(receiptRoot),
|
|
101
106
|
result: {
|
|
102
107
|
passed,
|
|
103
108
|
exit: result.status,
|
package/lib/worktree-core.js
CHANGED
|
@@ -906,6 +906,53 @@ function refreshMovedMapRefs(root, args = []) {
|
|
|
906
906
|
}
|
|
907
907
|
}
|
|
908
908
|
|
|
909
|
+
// The repo checks that failed CI for seven builders in three days. Ship runs
|
|
910
|
+
// them itself so the failure shows up before the push, not after a CI round trip.
|
|
911
|
+
const SHIP_NET_FILES = [
|
|
912
|
+
'test/repo-hygiene.test.js',
|
|
913
|
+
'test/no-hardcoded-home-paths.test.js',
|
|
914
|
+
'test/map-refs-accurate.test.js',
|
|
915
|
+
];
|
|
916
|
+
|
|
917
|
+
function shipNets(root) {
|
|
918
|
+
return SHIP_NET_FILES.filter((file) => fs.existsSync(path.join(root, file)));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Runs the nets once, before the caller's --verify. A net the verify command
|
|
922
|
+
// already names by its exact path is dropped so it does not run twice. Returns { ok, ran, files }.
|
|
923
|
+
function runShipNets(root, { verify = '', skip = false, dryRun = false, env = process.env } = {}) {
|
|
924
|
+
if (skip || env.ATRIS_SHIP_SKIP_NETS === '1') {
|
|
925
|
+
console.log(`nets: skipped (${skip ? '--skip-nets' : 'ATRIS_SHIP_SKIP_NETS=1'})`);
|
|
926
|
+
return { ok: true, ran: false, files: [] };
|
|
927
|
+
}
|
|
928
|
+
const present = shipNets(root);
|
|
929
|
+
if (!present.length) {
|
|
930
|
+
console.log('nets: none in this repo');
|
|
931
|
+
return { ok: true, ran: false, files: [] };
|
|
932
|
+
}
|
|
933
|
+
// Whole whitespace-separated tokens only: the exact path, or the same path with ./ in front.
|
|
934
|
+
const named = new Set(String(verify || '').split(/\s+/).map((token) => token.replace(/^(['"])(.*)\1$/, '$2')));
|
|
935
|
+
const files = present.filter((file) => !named.has(file) && !named.has(`./${file}`));
|
|
936
|
+
if (!files.length) {
|
|
937
|
+
console.log('nets: covered by --verify');
|
|
938
|
+
return { ok: true, ran: false, files: [] };
|
|
939
|
+
}
|
|
940
|
+
console.log(`nets: node --test ${files.join(' ')}`);
|
|
941
|
+
if (dryRun) {
|
|
942
|
+
console.log('nets: skipped (dry-run)');
|
|
943
|
+
return { ok: true, ran: false, files };
|
|
944
|
+
}
|
|
945
|
+
// A ship started from inside a test run must still get a real runner.
|
|
946
|
+
const childEnv = { ...env };
|
|
947
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
948
|
+
const result = spawnSync(process.execPath, ['--test', ...files], { cwd: root, stdio: 'inherit', env: childEnv });
|
|
949
|
+
if (result.error || result.status !== 0) {
|
|
950
|
+
console.error('nets: failed, fix before shipping');
|
|
951
|
+
return { ok: false, ran: true, files };
|
|
952
|
+
}
|
|
953
|
+
return { ok: true, ran: true, files };
|
|
954
|
+
}
|
|
955
|
+
|
|
909
956
|
const SHIP_SETTLE_MS = 10 * 60 * 1000;
|
|
910
957
|
const SHIP_REAP_POLL_MS = 60 * 1000;
|
|
911
958
|
const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
|
|
@@ -2014,9 +2061,11 @@ module.exports = {
|
|
|
2014
2061
|
runCommand,
|
|
2015
2062
|
runGit,
|
|
2016
2063
|
runShipHealthCheck,
|
|
2064
|
+
runShipNets,
|
|
2017
2065
|
scheduleShippedCopyRemoval,
|
|
2018
2066
|
shellQuote,
|
|
2019
2067
|
shipMergeRecord,
|
|
2068
|
+
shipNets,
|
|
2020
2069
|
shippedCopyRemovalPlan,
|
|
2021
2070
|
slugify,
|
|
2022
2071
|
stamp,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.82.
|
|
3
|
+
"version": "3.82.26",
|
|
4
4
|
"description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
|
|
5
5
|
"mcpName": "io.github.atrislabs/atris",
|
|
6
6
|
"main": "bin/atris.js",
|