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.
Files changed (131) hide show
  1. package/FOR_AGENTS.md +7 -0
  2. package/README.md +1 -1
  3. package/atris/policies/ANTISLOP.md +2 -1
  4. package/atris/skills/copy-editor/SKILL.md +2 -1
  5. package/atris/skills/design/SKILL.md +1 -1
  6. package/atris/skills/engines/SKILL.md +8 -7
  7. package/atris/skills/x-search/SKILL.md +24 -22
  8. package/atris/skills/youtube/SKILL.md +89 -40
  9. package/atris/team/_template/SOUL.md +40 -0
  10. package/atris/team/validator/MEMBER.md +1 -2
  11. package/ax +592 -91
  12. package/bin/atris +21 -0
  13. package/bin/atris.js +580 -378
  14. package/commands/api-key.js +170 -0
  15. package/commands/auth.js +453 -43
  16. package/commands/autoland.js +3 -1
  17. package/commands/autopilot-front.js +58 -11
  18. package/commands/avail.js +5 -4
  19. package/commands/balance.js +55 -0
  20. package/commands/bench.js +29 -1
  21. package/commands/brain.js +63 -12
  22. package/commands/brainstorm.js +18 -0
  23. package/commands/business.js +149 -1
  24. package/commands/chat-scan.js +17 -4
  25. package/commands/close.js +5 -1
  26. package/commands/computer.js +2 -2
  27. package/commands/doctor.js +174 -0
  28. package/commands/dream.js +7 -0
  29. package/commands/drill.js +34 -2
  30. package/commands/engine.js +47 -35
  31. package/commands/errors.js +58 -8
  32. package/commands/experiments.js +14 -0
  33. package/commands/feedback.js +61 -2
  34. package/commands/fleet-report.js +27 -7
  35. package/commands/fleet.js +98 -11
  36. package/commands/founder.js +60 -21
  37. package/commands/human-missions.js +79 -4
  38. package/commands/improve.js +54 -4
  39. package/commands/init.js +68 -92
  40. package/commands/install.js +40 -0
  41. package/commands/integrations.js +399 -30
  42. package/commands/interview.js +8 -0
  43. package/commands/land.js +48 -17
  44. package/commands/learn.js +29 -4
  45. package/commands/log.js +51 -0
  46. package/commands/member.js +68 -10
  47. package/commands/mission.js +339 -74
  48. package/commands/now.js +25 -6
  49. package/commands/orb.js +97 -2
  50. package/commands/pack.js +83 -9
  51. package/commands/playbook.js +381 -0
  52. package/commands/plugin.js +16 -0
  53. package/commands/radar.js +96 -40
  54. package/commands/recap.js +128 -21
  55. package/commands/release.js +102 -2
  56. package/commands/review.js +5 -10
  57. package/commands/revisions.js +55 -0
  58. package/commands/run-front.js +12 -4
  59. package/commands/scout.js +12 -1
  60. package/commands/search.js +123 -28
  61. package/commands/sign.js +2 -2
  62. package/commands/signup.js +12 -3
  63. package/commands/site-deploy.js +17 -4
  64. package/commands/site.js +2 -2
  65. package/commands/skill-eval.js +289 -0
  66. package/commands/skill.js +56 -6
  67. package/commands/social.js +603 -0
  68. package/commands/spaceship.js +69 -6
  69. package/commands/stream.js +8 -1
  70. package/commands/study.js +32 -19
  71. package/commands/sync.js +28 -22
  72. package/commands/task.js +418 -60
  73. package/commands/teach.js +72 -63
  74. package/commands/terminal.js +25 -9
  75. package/commands/topup.js +88 -0
  76. package/commands/truth.js +52 -6
  77. package/commands/usage.js +91 -0
  78. package/commands/verify.js +3 -2
  79. package/commands/version.js +3 -1
  80. package/commands/voice.js +5 -1
  81. package/commands/who.js +69 -11
  82. package/commands/wiki.js +7 -4
  83. package/commands/wish.js +76 -5
  84. package/commands/workflow.js +327 -145
  85. package/commands/worktree.js +1 -0
  86. package/commands/write.js +5 -0
  87. package/commands/x-search.js +340 -0
  88. package/commands/xp.js +5 -1
  89. package/commands/youtube.js +551 -33
  90. package/lib/account-bound.js +47 -0
  91. package/lib/auto-accept-certified.js +10 -2
  92. package/lib/auto-lessons.js +186 -0
  93. package/lib/autoland.js +4 -2
  94. package/lib/chat-log-scan.js +7 -4
  95. package/lib/cli-json.js +77 -0
  96. package/lib/cli-scope.js +26 -0
  97. package/lib/codex-flight.js +1 -0
  98. package/lib/conductor-artifacts.js +1 -1
  99. package/lib/context-gatherer.js +11 -0
  100. package/lib/developer-api.js +116 -0
  101. package/lib/engine-ask.js +11 -3
  102. package/lib/engine-registry.js +32 -7
  103. package/lib/feedback-triage.js +274 -0
  104. package/lib/first-minute.js +398 -0
  105. package/lib/fleet.js +115 -34
  106. package/lib/functional-owner.js +22 -0
  107. package/lib/known-commands.js +31 -5
  108. package/lib/member-scaffold.js +197 -0
  109. package/lib/mission-ledger-compact.js +127 -0
  110. package/lib/mission-root.js +4 -2
  111. package/lib/mission-runtime-loop.js +30 -2
  112. package/lib/next-moves.js +34 -34
  113. package/lib/noninteractive.js +88 -0
  114. package/lib/permission-grants.js +10 -1
  115. package/lib/policy-lessons.js +4 -1
  116. package/lib/revision-metric.js +279 -0
  117. package/lib/runner-command.js +5 -4
  118. package/lib/scratch-root.js +48 -0
  119. package/lib/skill-eval-gate.js +249 -0
  120. package/lib/task-db.js +22 -2
  121. package/lib/task-proof.js +75 -9
  122. package/lib/task-receipt.js +5 -1
  123. package/lib/verifier-quality.js +69 -0
  124. package/lib/wish-audit.js +2 -2
  125. package/lib/wish-delegate.js +10 -1
  126. package/lib/workspace-scaffold.js +270 -0
  127. package/package.json +4 -2
  128. package/scripts/det/checklist-score.js +191 -0
  129. package/scripts/det/ytsearch +31 -0
  130. package/scripts/outbound-artifact-gate.js +227 -0
  131. 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
+ };
@@ -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
- droid: Object.freeze({
71
- bin: 'droid',
72
- model: '',
73
- commandTemplate: '{bin} exec {prompt}',
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),