atris 3.52.0 → 3.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FOR_AGENTS.md +7 -0
- package/README.md +1 -1
- package/atris/policies/ANTISLOP.md +2 -1
- package/atris/skills/copy-editor/SKILL.md +2 -1
- package/atris/skills/design/SKILL.md +1 -1
- package/atris/skills/engines/SKILL.md +8 -7
- package/atris/skills/x-search/SKILL.md +24 -22
- package/atris/skills/youtube/SKILL.md +89 -40
- package/atris/team/_template/SOUL.md +40 -0
- package/atris/team/validator/MEMBER.md +1 -2
- package/ax +592 -91
- package/bin/atris +21 -0
- package/bin/atris.js +580 -378
- package/commands/api-key.js +170 -0
- package/commands/auth.js +453 -43
- package/commands/autoland.js +3 -1
- package/commands/autopilot-front.js +58 -11
- package/commands/avail.js +5 -4
- package/commands/balance.js +55 -0
- package/commands/bench.js +29 -1
- package/commands/brain.js +63 -12
- package/commands/brainstorm.js +18 -0
- package/commands/business.js +149 -1
- package/commands/chat-scan.js +17 -4
- package/commands/close.js +5 -1
- package/commands/computer.js +2 -2
- package/commands/doctor.js +174 -0
- package/commands/dream.js +7 -0
- package/commands/drill.js +34 -2
- package/commands/engine.js +47 -35
- package/commands/errors.js +58 -8
- package/commands/experiments.js +14 -0
- package/commands/feedback.js +61 -2
- package/commands/fleet-report.js +27 -7
- package/commands/fleet.js +98 -11
- package/commands/founder.js +60 -21
- package/commands/human-missions.js +79 -4
- package/commands/improve.js +54 -4
- package/commands/init.js +68 -92
- package/commands/install.js +40 -0
- package/commands/integrations.js +399 -30
- package/commands/interview.js +8 -0
- package/commands/land.js +48 -17
- package/commands/learn.js +29 -4
- package/commands/log.js +51 -0
- package/commands/member.js +68 -10
- package/commands/mission.js +339 -74
- package/commands/now.js +25 -6
- package/commands/orb.js +97 -2
- package/commands/pack.js +83 -9
- package/commands/playbook.js +381 -0
- package/commands/plugin.js +16 -0
- package/commands/radar.js +96 -40
- package/commands/recap.js +128 -21
- package/commands/release.js +102 -2
- package/commands/review.js +5 -10
- package/commands/revisions.js +55 -0
- package/commands/run-front.js +12 -4
- package/commands/scout.js +12 -1
- package/commands/search.js +123 -28
- package/commands/sign.js +2 -2
- package/commands/signup.js +12 -3
- package/commands/site-deploy.js +17 -4
- package/commands/site.js +2 -2
- package/commands/skill-eval.js +289 -0
- package/commands/skill.js +56 -6
- package/commands/social.js +603 -0
- package/commands/spaceship.js +69 -6
- package/commands/stream.js +8 -1
- package/commands/study.js +32 -19
- package/commands/sync.js +28 -22
- package/commands/task.js +418 -60
- package/commands/teach.js +72 -63
- package/commands/terminal.js +25 -9
- package/commands/topup.js +88 -0
- package/commands/truth.js +52 -6
- package/commands/usage.js +91 -0
- package/commands/verify.js +3 -2
- package/commands/version.js +3 -1
- package/commands/voice.js +5 -1
- package/commands/who.js +69 -11
- package/commands/wiki.js +7 -4
- package/commands/wish.js +76 -5
- package/commands/workflow.js +327 -145
- package/commands/worktree.js +1 -0
- package/commands/write.js +5 -0
- package/commands/x-search.js +340 -0
- package/commands/xp.js +5 -1
- package/commands/youtube.js +551 -33
- package/lib/account-bound.js +47 -0
- package/lib/auto-accept-certified.js +10 -2
- package/lib/auto-lessons.js +186 -0
- package/lib/autoland.js +4 -2
- package/lib/chat-log-scan.js +7 -4
- package/lib/cli-json.js +77 -0
- package/lib/cli-scope.js +26 -0
- package/lib/codex-flight.js +1 -0
- package/lib/conductor-artifacts.js +1 -1
- package/lib/context-gatherer.js +11 -0
- package/lib/developer-api.js +116 -0
- package/lib/engine-ask.js +11 -3
- package/lib/engine-registry.js +32 -7
- package/lib/feedback-triage.js +274 -0
- package/lib/first-minute.js +398 -0
- package/lib/fleet.js +115 -34
- package/lib/functional-owner.js +22 -0
- package/lib/known-commands.js +31 -5
- package/lib/member-scaffold.js +197 -0
- package/lib/mission-ledger-compact.js +127 -0
- package/lib/mission-root.js +4 -2
- package/lib/mission-runtime-loop.js +30 -2
- package/lib/next-moves.js +34 -34
- package/lib/noninteractive.js +88 -0
- package/lib/permission-grants.js +10 -1
- package/lib/policy-lessons.js +4 -1
- package/lib/revision-metric.js +279 -0
- package/lib/runner-command.js +5 -4
- package/lib/scratch-root.js +48 -0
- package/lib/skill-eval-gate.js +249 -0
- package/lib/task-db.js +22 -2
- package/lib/task-proof.js +75 -9
- package/lib/task-receipt.js +5 -1
- package/lib/verifier-quality.js +69 -0
- package/lib/wish-audit.js +2 -2
- package/lib/wish-delegate.js +10 -1
- package/lib/workspace-scaffold.js +270 -0
- package/package.json +4 -2
- package/scripts/det/checklist-score.js +191 -0
- package/scripts/det/ytsearch +31 -0
- package/scripts/outbound-artifact-gate.js +227 -0
- package/utils/update-check.js +15 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const SCHEMA = 'atris.revision_metric.v1';
|
|
8
|
+
const DAILY_SCHEMA = 'atris.revision_metric.daily.v1';
|
|
9
|
+
const ROLLING_DAYS = 7;
|
|
10
|
+
const REVISION_WINDOW_HOURS = 72;
|
|
11
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
12
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
13
|
+
const REVISION_WINDOW_MS = REVISION_WINDOW_HOURS * HOUR_MS;
|
|
14
|
+
const ATRIS_TRAILER_RE = /^\s*co-authored-by\s*:.*atris-builder\[bot\]/im;
|
|
15
|
+
const IGNORED_FILES = [
|
|
16
|
+
/^atris\/logs\//,
|
|
17
|
+
/^atris\/runs\//,
|
|
18
|
+
/^atris\/team\/[^/]+\/(?:logs|now\.md)/,
|
|
19
|
+
/^atris\/(?:now|thinking|TODO)\.md$/,
|
|
20
|
+
/^atris\/MAP\.md$/,
|
|
21
|
+
/^atris\/brain\//,
|
|
22
|
+
/^atris\/status\//,
|
|
23
|
+
/^test\/fast-tests\.txt$/,
|
|
24
|
+
/^\.atris\//,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function git(root, args) {
|
|
28
|
+
const result = spawnSync('git', args, {
|
|
29
|
+
cwd: root,
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
if (result.error) throw result.error;
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
const message = String(result.stderr || result.stdout || `git ${args[0]} exited ${result.status}`).trim();
|
|
36
|
+
const error = new Error(message);
|
|
37
|
+
error.gitFailed = true;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return String(result.stdout || '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hasRef(root, ref) {
|
|
44
|
+
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], {
|
|
45
|
+
cwd: root,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
});
|
|
48
|
+
return result.status === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveHistoryRef(root, requestedRef) {
|
|
52
|
+
const candidates = requestedRef ? [requestedRef] : ['origin/master', 'master', 'HEAD'];
|
|
53
|
+
const ref = candidates.find((candidate) => hasRef(root, candidate));
|
|
54
|
+
if (!ref) {
|
|
55
|
+
const error = new Error('this folder has no readable git history');
|
|
56
|
+
error.gitFailed = true;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return ref;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isAtrisAssistedBody(body) {
|
|
63
|
+
return ATRIS_TRAILER_RE.test(String(body || ''));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRevisionSignalFile(file) {
|
|
67
|
+
const name = String(file || '').trim();
|
|
68
|
+
return Boolean(name) && !IGNORED_FILES.some((pattern) => pattern.test(name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseMainlineLog(raw) {
|
|
72
|
+
return String(raw || '')
|
|
73
|
+
.split('\x1e')
|
|
74
|
+
.map((record) => record.replace(/^\n+/, ''))
|
|
75
|
+
.filter((record) => record.trim())
|
|
76
|
+
.map((record) => {
|
|
77
|
+
const [hash, parents, at, authorName, authorEmail, subject, body] = record.split('\x1f');
|
|
78
|
+
return {
|
|
79
|
+
hash: String(hash || '').trim(),
|
|
80
|
+
parents: String(parents || '').trim().split(/\s+/).filter(Boolean),
|
|
81
|
+
at: String(at || '').trim(),
|
|
82
|
+
ms: new Date(at).getTime(),
|
|
83
|
+
authorName: String(authorName || '').trim(),
|
|
84
|
+
authorEmail: String(authorEmail || '').trim(),
|
|
85
|
+
subject: String(subject || '').trim(),
|
|
86
|
+
body: String(body || ''),
|
|
87
|
+
};
|
|
88
|
+
})
|
|
89
|
+
.filter((commit) => commit.hash && Number.isFinite(commit.ms));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readMainlineCommits(root, ref, sinceIso) {
|
|
93
|
+
const raw = git(root, [
|
|
94
|
+
'log', ref, '--first-parent', '--reverse', `--since=${sinceIso}`,
|
|
95
|
+
'--date=iso-strict',
|
|
96
|
+
'--pretty=format:%H%x1f%P%x1f%aI%x1f%aN%x1f%aE%x1f%s%x1f%B%x1e',
|
|
97
|
+
]);
|
|
98
|
+
return parseMainlineLog(raw);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function commitFiles(root, commit) {
|
|
102
|
+
const args = commit.parents.length
|
|
103
|
+
? ['diff', '--name-only', commit.parents[0], commit.hash]
|
|
104
|
+
: ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', commit.hash];
|
|
105
|
+
return git(root, args).split(/\r?\n/).map((file) => file.trim()).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sideParentIsAtrisAssisted(root, commit, bodyCache) {
|
|
109
|
+
if (commit.parents.length < 2) return false;
|
|
110
|
+
return commit.parents.slice(1).some((parent) => {
|
|
111
|
+
if (!bodyCache.has(parent)) bodyCache.set(parent, git(root, ['show', '-s', '--format=%B', parent]));
|
|
112
|
+
return isAtrisAssistedBody(bodyCache.get(parent));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isHumanAuthor(commit) {
|
|
117
|
+
const identity = `${commit.authorName} ${commit.authorEmail}`;
|
|
118
|
+
return !/\[bot\]|(?:^|[+._-])bot@/i.test(identity);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function summarizeChanges(changes, nowMs, excludedLandings) {
|
|
122
|
+
const revisionCount = changes.reduce((sum, change) => sum + change.revision_count, 0);
|
|
123
|
+
const revisedChanges = changes.filter((change) => change.revision_count > 0).length;
|
|
124
|
+
const stillObserving = changes.filter((change) => nowMs - new Date(change.landed_at).getTime() < REVISION_WINDOW_MS).length;
|
|
125
|
+
const matureCleanChanges = changes.filter((change) => (
|
|
126
|
+
change.revision_count === 0
|
|
127
|
+
&& nowMs - new Date(change.landed_at).getTime() >= REVISION_WINDOW_MS
|
|
128
|
+
)).length;
|
|
129
|
+
return {
|
|
130
|
+
days: ROLLING_DAYS,
|
|
131
|
+
landed_changes: changes.length,
|
|
132
|
+
revision_count: revisionCount,
|
|
133
|
+
revised_changes: revisedChanges,
|
|
134
|
+
clean_so_far: changes.length - revisedChanges,
|
|
135
|
+
mature_clean_changes: matureCleanChanges,
|
|
136
|
+
still_observing: stillObserving,
|
|
137
|
+
excluded_state_only_landings: excludedLandings,
|
|
138
|
+
target_revision_count: 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function computeRevisionMetric(root = process.cwd(), options = {}) {
|
|
143
|
+
const now = options.now == null ? new Date() : new Date(options.now);
|
|
144
|
+
const nowMs = now.getTime();
|
|
145
|
+
if (!Number.isFinite(nowMs)) throw new Error('now must be a valid date');
|
|
146
|
+
const ref = resolveHistoryRef(root, options.ref);
|
|
147
|
+
const windowStart = new Date(nowMs - ROLLING_DAYS * DAY_MS);
|
|
148
|
+
const commits = readMainlineCommits(root, ref, windowStart.toISOString())
|
|
149
|
+
.filter((commit) => commit.ms <= nowMs);
|
|
150
|
+
const bodyCache = new Map();
|
|
151
|
+
const fileCache = new Map();
|
|
152
|
+
const filesFor = (commit) => {
|
|
153
|
+
if (!fileCache.has(commit.hash)) fileCache.set(commit.hash, commitFiles(root, commit));
|
|
154
|
+
return fileCache.get(commit.hash);
|
|
155
|
+
};
|
|
156
|
+
const changes = [];
|
|
157
|
+
const latestLandingByFile = new Map();
|
|
158
|
+
let excludedLandings = 0;
|
|
159
|
+
|
|
160
|
+
for (const commit of commits) {
|
|
161
|
+
const atrisAssisted = isAtrisAssistedBody(commit.body)
|
|
162
|
+
|| sideParentIsAtrisAssisted(root, commit, bodyCache);
|
|
163
|
+
const files = filesFor(commit).filter(isRevisionSignalFile);
|
|
164
|
+
|
|
165
|
+
if (atrisAssisted) {
|
|
166
|
+
if (!files.length) {
|
|
167
|
+
excludedLandings += 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const change = {
|
|
171
|
+
commit: commit.hash,
|
|
172
|
+
subject: commit.subject,
|
|
173
|
+
landed_at: commit.at,
|
|
174
|
+
files: [...new Set(files)].sort(),
|
|
175
|
+
revision_count: 0,
|
|
176
|
+
revisions: [],
|
|
177
|
+
};
|
|
178
|
+
const index = changes.push(change) - 1;
|
|
179
|
+
change.files.forEach((file) => latestLandingByFile.set(file, index));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (commit.parents.length > 1 || !isHumanAuthor(commit) || !files.length) continue;
|
|
184
|
+
const affected = new Map();
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const landingIndex = latestLandingByFile.get(file);
|
|
187
|
+
if (landingIndex == null) continue;
|
|
188
|
+
if (!affected.has(landingIndex)) affected.set(landingIndex, []);
|
|
189
|
+
affected.get(landingIndex).push(file);
|
|
190
|
+
}
|
|
191
|
+
for (const [landingIndex, overlap] of affected) {
|
|
192
|
+
const landing = changes[landingIndex];
|
|
193
|
+
const elapsed = commit.ms - new Date(landing.landed_at).getTime();
|
|
194
|
+
if (elapsed <= 0 || elapsed > REVISION_WINDOW_MS) continue;
|
|
195
|
+
landing.revisions.push({
|
|
196
|
+
commit: commit.hash,
|
|
197
|
+
subject: commit.subject,
|
|
198
|
+
at: commit.at,
|
|
199
|
+
files: [...new Set(overlap)].sort(),
|
|
200
|
+
});
|
|
201
|
+
landing.revision_count += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
schema: SCHEMA,
|
|
207
|
+
generated_at: now.toISOString(),
|
|
208
|
+
source_ref: ref,
|
|
209
|
+
window_start: windowStart.toISOString(),
|
|
210
|
+
window_end: now.toISOString(),
|
|
211
|
+
signal: {
|
|
212
|
+
kind: 'proxy',
|
|
213
|
+
source: 'git_history',
|
|
214
|
+
landing: 'an atris co-author trailer on a mainline commit or its merged side parent',
|
|
215
|
+
revision: `a later non-merge human commit touching the latest landing's files within ${REVISION_WINDOW_HOURS} hours`,
|
|
216
|
+
limitation: 'the trailer proves assistance, not automatic landing; same-file overlap does not prove why the file changed; human commits carrying the trailer cannot be separated',
|
|
217
|
+
revision_window_hours: REVISION_WINDOW_HOURS,
|
|
218
|
+
},
|
|
219
|
+
changes,
|
|
220
|
+
rolling_7_days: summarizeChanges(changes, nowMs, excludedLandings),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function utcDate(value) {
|
|
225
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function dailyRow(metric) {
|
|
229
|
+
const summary = metric.rolling_7_days;
|
|
230
|
+
return {
|
|
231
|
+
schema: DAILY_SCHEMA,
|
|
232
|
+
date: utcDate(metric.generated_at),
|
|
233
|
+
day_basis: 'utc',
|
|
234
|
+
generated_at: metric.generated_at,
|
|
235
|
+
source_ref: metric.source_ref,
|
|
236
|
+
rolling_days: summary.days,
|
|
237
|
+
revision_window_hours: metric.signal.revision_window_hours,
|
|
238
|
+
landed_changes: summary.landed_changes,
|
|
239
|
+
revision_count: summary.revision_count,
|
|
240
|
+
revised_changes: summary.revised_changes,
|
|
241
|
+
clean_so_far: summary.clean_so_far,
|
|
242
|
+
mature_clean_changes: summary.mature_clean_changes,
|
|
243
|
+
still_observing: summary.still_observing,
|
|
244
|
+
excluded_state_only_landings: summary.excluded_state_only_landings,
|
|
245
|
+
measurement_kind: metric.signal.kind,
|
|
246
|
+
signal_source: metric.signal.source,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function appendDailySummary(root, metric, options = {}) {
|
|
251
|
+
const file = options.file || path.join(root, '.atris', 'state', 'revision-metric.jsonl');
|
|
252
|
+
const row = dailyRow(metric);
|
|
253
|
+
let existing = [];
|
|
254
|
+
try {
|
|
255
|
+
existing = fs.readFileSync(file, 'utf8')
|
|
256
|
+
.split(/\r?\n/)
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.map((line) => {
|
|
259
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
260
|
+
})
|
|
261
|
+
.filter(Boolean);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error.code !== 'ENOENT') throw error;
|
|
264
|
+
}
|
|
265
|
+
const sameDay = existing.find((item) => item.schema === DAILY_SCHEMA && item.date === row.date);
|
|
266
|
+
if (sameDay) {
|
|
267
|
+
return { appended: false, row: sameDay, path: path.relative(root, file) };
|
|
268
|
+
}
|
|
269
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
270
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
|
|
271
|
+
return { appended: true, row, path: path.relative(root, file) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
REVISION_WINDOW_HOURS,
|
|
276
|
+
appendDailySummary,
|
|
277
|
+
computeRevisionMetric,
|
|
278
|
+
isRevisionSignalFile,
|
|
279
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -67,10 +67,10 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
67
67
|
model: 'grok-4.6',
|
|
68
68
|
commandTemplate: '{bin} --always-approve -p {prompt}',
|
|
69
69
|
}),
|
|
70
|
-
|
|
71
|
-
bin: '
|
|
72
|
-
model: '',
|
|
73
|
-
commandTemplate: '{bin}
|
|
70
|
+
agy: Object.freeze({
|
|
71
|
+
bin: 'agy',
|
|
72
|
+
model: 'gemini-3.7-flash-high',
|
|
73
|
+
commandTemplate: '{bin} --mode accept-edits {modelFlag} -p {prompt}',
|
|
74
74
|
}),
|
|
75
75
|
});
|
|
76
76
|
|
|
@@ -80,6 +80,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
80
80
|
const RUNNER_PROFILE_ALIASES = Object.freeze({
|
|
81
81
|
'atris2-fast': 'atris-fast',
|
|
82
82
|
'atris-2-fast': 'atris-fast',
|
|
83
|
+
antigravity: 'agy',
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
// Back-compat surface: RUNNER_PROFILES still resolves every accepted name
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// Generic scratch roots. A workspace may live here (dogfood happens), but a
|
|
8
|
+
// child folder is its own room and must not inherit that parent.
|
|
9
|
+
// first-minute folderName already treats these as "this folder".
|
|
10
|
+
const NAMED_SCRATCH_ROOTS = ['/tmp', '/private/tmp', '/var/tmp'];
|
|
11
|
+
|
|
12
|
+
function pathKey(dir) {
|
|
13
|
+
return path.resolve(dir || '.').replace(/\\/g, '/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function genericScratchRoots() {
|
|
17
|
+
const keys = new Set(NAMED_SCRATCH_ROOTS.map(pathKey));
|
|
18
|
+
try {
|
|
19
|
+
keys.add(pathKey(os.tmpdir()));
|
|
20
|
+
} catch {
|
|
21
|
+
// os.tmpdir can throw in locked-down hosts.
|
|
22
|
+
}
|
|
23
|
+
const resolved = new Set(keys);
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
try {
|
|
26
|
+
resolved.add(pathKey(fs.realpathSync(key)));
|
|
27
|
+
} catch {
|
|
28
|
+
// Missing or unreadable roots stay as the unresolved key.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isGenericScratchRoot(dir) {
|
|
35
|
+
if (!dir) return false;
|
|
36
|
+
const roots = genericScratchRoots();
|
|
37
|
+
const key = pathKey(dir);
|
|
38
|
+
if (roots.has(key)) return true;
|
|
39
|
+
try {
|
|
40
|
+
return roots.has(pathKey(fs.realpathSync(dir)));
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
isGenericScratchRoot,
|
|
48
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const SKILL_EVAL_SCHEMA = 'atris.skill_eval.v1';
|
|
8
|
+
const SCORECARDS_PATH = path.join('.atris', 'state', 'scorecards.jsonl');
|
|
9
|
+
const BASE_REFS = Object.freeze(['origin/master', 'origin/main', 'master', 'main']);
|
|
10
|
+
|
|
11
|
+
function runGit(root, args) {
|
|
12
|
+
try {
|
|
13
|
+
return spawnSync('git', args, {
|
|
14
|
+
cwd: root,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
timeout: 10000,
|
|
17
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return { status: 1, stdout: '', stderr: String(error && error.message ? error.message : error) };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizedRelativePath(root, value) {
|
|
25
|
+
const text = String(value || '').trim().replace(/\\/g, '/');
|
|
26
|
+
if (!text || text.includes('\0') || path.isAbsolute(text)) return null;
|
|
27
|
+
const absolute = path.resolve(root, text);
|
|
28
|
+
const relative = path.relative(root, absolute).replace(/\\/g, '/');
|
|
29
|
+
if (!relative || relative === '..' || relative.startsWith('../')) return null;
|
|
30
|
+
return relative;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nulTerminatedPaths(result, root) {
|
|
34
|
+
if (!result || result.status !== 0) return [];
|
|
35
|
+
return String(result.stdout || '')
|
|
36
|
+
.split('\0')
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((entry) => normalizedRelativePath(root, entry))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveBaseRef(root) {
|
|
43
|
+
const head = runGit(root, ['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
44
|
+
if (head.status !== 0) return null;
|
|
45
|
+
for (const ref of BASE_REFS) {
|
|
46
|
+
const candidate = runGit(root, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
47
|
+
if (candidate.status === 0) return ref;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function indexMtime(root) {
|
|
53
|
+
const result = runGit(root, ['rev-parse', '--git-path', 'index']);
|
|
54
|
+
if (result.status !== 0) return 0;
|
|
55
|
+
const value = String(result.stdout || '').trim();
|
|
56
|
+
const file = path.isAbsolute(value) ? value : path.resolve(root, value);
|
|
57
|
+
try {
|
|
58
|
+
return fs.statSync(file).mtimeMs;
|
|
59
|
+
} catch {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fileChangeMtime(root, skillPath) {
|
|
65
|
+
const absolute = path.join(root, skillPath);
|
|
66
|
+
try {
|
|
67
|
+
return fs.statSync(absolute).mtimeMs;
|
|
68
|
+
} catch {
|
|
69
|
+
try {
|
|
70
|
+
return fs.statSync(path.dirname(absolute)).mtimeMs;
|
|
71
|
+
} catch {
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function committedChangeMtime(root, baseRef, skillPath) {
|
|
78
|
+
if (!baseRef) return 0;
|
|
79
|
+
const result = runGit(root, [
|
|
80
|
+
'log',
|
|
81
|
+
'-1',
|
|
82
|
+
'--format=%cI',
|
|
83
|
+
`${baseRef}..HEAD`,
|
|
84
|
+
'--',
|
|
85
|
+
skillPath,
|
|
86
|
+
]);
|
|
87
|
+
if (result.status !== 0) return 0;
|
|
88
|
+
const parsed = Date.parse(String(result.stdout || '').trim());
|
|
89
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function changedSkillFiles(workspaceRoot = process.cwd()) {
|
|
93
|
+
const root = path.resolve(workspaceRoot);
|
|
94
|
+
const inside = runGit(root, ['rev-parse', '--is-inside-work-tree']);
|
|
95
|
+
if (inside.status !== 0 || String(inside.stdout || '').trim() !== 'true') return [];
|
|
96
|
+
|
|
97
|
+
const changes = new Map();
|
|
98
|
+
const add = (source, paths) => {
|
|
99
|
+
for (const relative of paths) {
|
|
100
|
+
if (path.posix.basename(relative) !== 'SKILL.md') continue;
|
|
101
|
+
const current = changes.get(relative) || new Set();
|
|
102
|
+
current.add(source);
|
|
103
|
+
changes.set(relative, current);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
add('worktree', nulTerminatedPaths(runGit(root, ['diff', '--name-only', '-z']), root));
|
|
108
|
+
add('staged', nulTerminatedPaths(runGit(root, ['diff', '--cached', '--name-only', '-z']), root));
|
|
109
|
+
add('untracked', nulTerminatedPaths(runGit(root, ['ls-files', '--others', '--exclude-standard', '-z']), root));
|
|
110
|
+
|
|
111
|
+
const baseRef = resolveBaseRef(root);
|
|
112
|
+
if (baseRef) {
|
|
113
|
+
add('committed', nulTerminatedPaths(
|
|
114
|
+
runGit(root, ['diff', '--name-only', '-z', `${baseRef}...HEAD`]),
|
|
115
|
+
root,
|
|
116
|
+
));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stagedAt = indexMtime(root);
|
|
120
|
+
return [...changes.entries()]
|
|
121
|
+
.map(([skillPath, sources]) => {
|
|
122
|
+
let changedAtMs = 0;
|
|
123
|
+
if (sources.has('worktree') || sources.has('untracked')) {
|
|
124
|
+
changedAtMs = Math.max(changedAtMs, fileChangeMtime(root, skillPath));
|
|
125
|
+
}
|
|
126
|
+
if (sources.has('staged')) changedAtMs = Math.max(changedAtMs, stagedAt);
|
|
127
|
+
if (sources.has('committed')) {
|
|
128
|
+
changedAtMs = Math.max(changedAtMs, committedChangeMtime(root, baseRef, skillPath));
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
path: skillPath,
|
|
132
|
+
changed_at_ms: changedAtMs,
|
|
133
|
+
changed_at: changedAtMs > 0 ? new Date(changedAtMs).toISOString() : null,
|
|
134
|
+
sources: [...sources].sort(),
|
|
135
|
+
};
|
|
136
|
+
})
|
|
137
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readSkillEvalReceipts(workspaceRoot = process.cwd()) {
|
|
141
|
+
const root = path.resolve(workspaceRoot);
|
|
142
|
+
const file = path.join(root, SCORECARDS_PATH);
|
|
143
|
+
if (!fs.existsSync(file)) return [];
|
|
144
|
+
const rows = [];
|
|
145
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
if (!trimmed) continue;
|
|
148
|
+
try {
|
|
149
|
+
const row = JSON.parse(trimmed);
|
|
150
|
+
if (row && row.schema === SKILL_EVAL_SCHEMA) rows.push(row);
|
|
151
|
+
} catch {
|
|
152
|
+
// Foreign and malformed scorecard rows do not prove a skill evaluation.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasRubricScores(value) {
|
|
159
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
160
|
+
const scores = Object.values(value);
|
|
161
|
+
return scores.length > 0 && scores.every((score) => Number.isFinite(score));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizedIdentity(value) {
|
|
165
|
+
return String(value || '').trim().toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateSkillEvalReceipt(receipt, skill) {
|
|
169
|
+
if (!receipt || receipt.schema !== SKILL_EVAL_SCHEMA) {
|
|
170
|
+
return { ok: false, reason: 'wrong_schema' };
|
|
171
|
+
}
|
|
172
|
+
if (String(receipt.skill_path || '').replace(/\\/g, '/') !== skill.path) {
|
|
173
|
+
return { ok: false, reason: 'wrong_skill_path' };
|
|
174
|
+
}
|
|
175
|
+
const receiptAtMs = Date.parse(String(receipt.ts || ''));
|
|
176
|
+
if (!Number.isFinite(receiptAtMs) || receiptAtMs <= Number(skill.changed_at_ms || 0)) {
|
|
177
|
+
return { ok: false, reason: 'stale_receipt' };
|
|
178
|
+
}
|
|
179
|
+
if (receipt.passed !== true) return { ok: false, reason: 'eval_did_not_pass' };
|
|
180
|
+
if (!hasRubricScores(receipt.rubric_scores)) {
|
|
181
|
+
return { ok: false, reason: 'rubric_scores_missing' };
|
|
182
|
+
}
|
|
183
|
+
const worker = normalizedIdentity(receipt.worker_model);
|
|
184
|
+
const judge = normalizedIdentity(receipt.judge_identity);
|
|
185
|
+
if (!worker || !judge) return { ok: false, reason: 'judge_or_worker_missing' };
|
|
186
|
+
if (worker === judge) return { ok: false, reason: 'judge_matches_worker' };
|
|
187
|
+
return { ok: true, receipt_at_ms: receiptAtMs };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function skillEvalGate(workspaceRoot = process.cwd()) {
|
|
191
|
+
const root = path.resolve(workspaceRoot);
|
|
192
|
+
const skills = changedSkillFiles(root);
|
|
193
|
+
if (!skills.length) return { ok: true, skipped: true, skills: [] };
|
|
194
|
+
|
|
195
|
+
const receipts = readSkillEvalReceipts(root);
|
|
196
|
+
const missing = [];
|
|
197
|
+
const accepted = [];
|
|
198
|
+
for (const skill of skills) {
|
|
199
|
+
const candidates = receipts.filter((receipt) => (
|
|
200
|
+
String(receipt.skill_path || '').replace(/\\/g, '/') === skill.path
|
|
201
|
+
));
|
|
202
|
+
const valid = candidates
|
|
203
|
+
.map((receipt) => ({ receipt, result: validateSkillEvalReceipt(receipt, skill) }))
|
|
204
|
+
.filter((entry) => entry.result.ok)
|
|
205
|
+
.sort((a, b) => b.result.receipt_at_ms - a.result.receipt_at_ms)[0];
|
|
206
|
+
if (valid) {
|
|
207
|
+
accepted.push({ path: skill.path, receipt_ts: valid.receipt.ts });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const latest = candidates
|
|
211
|
+
.map((receipt) => ({ receipt, parsed: Date.parse(String(receipt.ts || '')) }))
|
|
212
|
+
.filter((entry) => Number.isFinite(entry.parsed))
|
|
213
|
+
.sort((a, b) => b.parsed - a.parsed)[0];
|
|
214
|
+
const validation = latest ? validateSkillEvalReceipt(latest.receipt, skill) : null;
|
|
215
|
+
missing.push({
|
|
216
|
+
path: skill.path,
|
|
217
|
+
changed_at: skill.changed_at,
|
|
218
|
+
reason: validation ? validation.reason : 'receipt_missing',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!missing.length) return { ok: true, skills, receipts: accepted };
|
|
223
|
+
const commands = missing.map((entry) => `atris skill eval ${entry.path}`);
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
reason: 'skill_eval_receipt_required',
|
|
227
|
+
offenders: missing.map((entry) => entry.path),
|
|
228
|
+
missing,
|
|
229
|
+
message: 'a changed skill has no fresh passing evaluation from a judge different from its worker model, so this work cannot land yet.',
|
|
230
|
+
next_action: commands.length === 1
|
|
231
|
+
? `run \`${commands[0]}\`, then re-certify`
|
|
232
|
+
: `run a fresh skill evaluation for each changed skill, then re-certify: ${commands.join(', ')}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function appendSkillEvalReceipt(workspaceRoot, receipt) {
|
|
237
|
+
const root = path.resolve(workspaceRoot || process.cwd());
|
|
238
|
+
const file = path.join(root, SCORECARDS_PATH);
|
|
239
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
240
|
+
fs.appendFileSync(file, `${JSON.stringify(receipt)}\n`, 'utf8');
|
|
241
|
+
return file;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
SKILL_EVAL_SCHEMA,
|
|
246
|
+
appendSkillEvalReceipt,
|
|
247
|
+
changedSkillFiles,
|
|
248
|
+
skillEvalGate,
|
|
249
|
+
};
|
package/lib/task-db.js
CHANGED
|
@@ -28,6 +28,7 @@ const os = require('os');
|
|
|
28
28
|
const crypto = require('crypto');
|
|
29
29
|
const { DatabaseSync } = require('node:sqlite');
|
|
30
30
|
const reviewIntegrity = require('./review-integrity');
|
|
31
|
+
const { isGenericScratchRoot } = require('./scratch-root');
|
|
31
32
|
const { isDecisionTask } = require('./task-decision');
|
|
32
33
|
const { parseVerifyCommand } = require('./auto-accept-certified');
|
|
33
34
|
const { taskExplanation } = require('./task-explanation');
|
|
@@ -269,6 +270,9 @@ function findWorkspaceRoot(start) {
|
|
|
269
270
|
if (test(cur)) return cur;
|
|
270
271
|
const parent = path.dirname(cur);
|
|
271
272
|
if (parent === cur) break;
|
|
273
|
+
// /tmp (and the other first-minute scratch roots) may themselves be a
|
|
274
|
+
// workspace. A child of that root is a new room and must not inherit it.
|
|
275
|
+
if (isGenericScratchRoot(parent)) break;
|
|
272
276
|
cur = parent;
|
|
273
277
|
}
|
|
274
278
|
return null;
|
|
@@ -1965,11 +1969,17 @@ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
|
|
|
1965
1969
|
return {
|
|
1966
1970
|
visibleRows: rows,
|
|
1967
1971
|
hiddenDoneCount: 0,
|
|
1972
|
+
hiddenArchivedCount: 0,
|
|
1968
1973
|
};
|
|
1969
1974
|
}
|
|
1970
1975
|
const visibleRows = [];
|
|
1971
1976
|
let shownDone = 0;
|
|
1972
1977
|
let hiddenDoneCount = 0;
|
|
1978
|
+
// Archived rows get the same recency cap as done rows. Without it the
|
|
1979
|
+
// projection carries every archived task forever (full events/messages) and
|
|
1980
|
+
// the file balloons; `--history` or a taskId still returns everything.
|
|
1981
|
+
let shownArchived = 0;
|
|
1982
|
+
let hiddenArchivedCount = 0;
|
|
1973
1983
|
for (const row of rows) {
|
|
1974
1984
|
if (row.status === 'done') {
|
|
1975
1985
|
if (shownDone < doneLimit) {
|
|
@@ -1980,9 +1990,18 @@ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
|
|
|
1980
1990
|
}
|
|
1981
1991
|
continue;
|
|
1982
1992
|
}
|
|
1993
|
+
if (row.status === 'archived') {
|
|
1994
|
+
if (shownArchived < doneLimit) {
|
|
1995
|
+
visibleRows.push(row);
|
|
1996
|
+
shownArchived += 1;
|
|
1997
|
+
} else {
|
|
1998
|
+
hiddenArchivedCount += 1;
|
|
1999
|
+
}
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
1983
2002
|
visibleRows.push(row);
|
|
1984
2003
|
}
|
|
1985
|
-
return { visibleRows, hiddenDoneCount };
|
|
2004
|
+
return { visibleRows, hiddenDoneCount, hiddenArchivedCount };
|
|
1986
2005
|
}
|
|
1987
2006
|
|
|
1988
2007
|
function taskProjection(db, {
|
|
@@ -2004,7 +2023,7 @@ function taskProjection(db, {
|
|
|
2004
2023
|
display_id: row.display_id,
|
|
2005
2024
|
legacy_ref: row.legacy_ref,
|
|
2006
2025
|
}]));
|
|
2007
|
-
const { visibleRows, hiddenDoneCount } = selectProjectionRows(rows, {
|
|
2026
|
+
const { visibleRows, hiddenDoneCount, hiddenArchivedCount } = selectProjectionRows(rows, {
|
|
2008
2027
|
taskId,
|
|
2009
2028
|
includeHistory,
|
|
2010
2029
|
doneLimit: Math.max(0, Number(doneLimit) || 0),
|
|
@@ -2028,6 +2047,7 @@ function taskProjection(db, {
|
|
|
2028
2047
|
full_task_count: rows.length,
|
|
2029
2048
|
visible_task_count: visibleRows.length,
|
|
2030
2049
|
hidden_done_count: hiddenDoneCount,
|
|
2050
|
+
hidden_archived_count: hiddenArchivedCount,
|
|
2031
2051
|
done_limit: includeHistory ? null : Math.max(0, Number(doneLimit) || 0),
|
|
2032
2052
|
event_limit: includeHistory ? null : Math.max(0, Number(eventLimit) || 0),
|
|
2033
2053
|
message_limit: includeHistory ? null : Math.max(0, Number(messageLimit) || 0),
|