atris 3.53.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 (119) 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/x-search/SKILL.md +24 -22
  7. package/atris/skills/youtube/SKILL.md +82 -33
  8. package/atris/team/_template/SOUL.md +40 -0
  9. package/atris/team/validator/MEMBER.md +1 -2
  10. package/bin/atris +21 -0
  11. package/bin/atris.js +563 -373
  12. package/commands/api-key.js +170 -0
  13. package/commands/auth.js +453 -43
  14. package/commands/autoland.js +3 -1
  15. package/commands/autopilot-front.js +58 -11
  16. package/commands/avail.js +5 -4
  17. package/commands/balance.js +55 -0
  18. package/commands/bench.js +29 -1
  19. package/commands/brain.js +63 -12
  20. package/commands/brainstorm.js +18 -0
  21. package/commands/business.js +149 -1
  22. package/commands/chat-scan.js +17 -4
  23. package/commands/close.js +5 -1
  24. package/commands/doctor.js +174 -0
  25. package/commands/dream.js +7 -0
  26. package/commands/drill.js +34 -2
  27. package/commands/engine.js +47 -35
  28. package/commands/errors.js +58 -8
  29. package/commands/experiments.js +14 -0
  30. package/commands/feedback.js +61 -2
  31. package/commands/fleet-report.js +27 -7
  32. package/commands/fleet.js +98 -11
  33. package/commands/founder.js +60 -21
  34. package/commands/human-missions.js +79 -4
  35. package/commands/improve.js +54 -4
  36. package/commands/init.js +68 -92
  37. package/commands/install.js +40 -0
  38. package/commands/integrations.js +399 -30
  39. package/commands/interview.js +8 -0
  40. package/commands/land.js +48 -17
  41. package/commands/learn.js +29 -4
  42. package/commands/log.js +51 -0
  43. package/commands/member.js +68 -10
  44. package/commands/mission.js +287 -50
  45. package/commands/now.js +15 -3
  46. package/commands/orb.js +97 -2
  47. package/commands/pack.js +83 -9
  48. package/commands/playbook.js +381 -0
  49. package/commands/plugin.js +16 -0
  50. package/commands/radar.js +31 -7
  51. package/commands/recap.js +128 -21
  52. package/commands/release.js +102 -2
  53. package/commands/review.js +5 -10
  54. package/commands/revisions.js +55 -0
  55. package/commands/run-front.js +12 -4
  56. package/commands/scout.js +12 -1
  57. package/commands/search.js +123 -28
  58. package/commands/sign.js +2 -2
  59. package/commands/signup.js +12 -3
  60. package/commands/site-deploy.js +17 -4
  61. package/commands/site.js +2 -2
  62. package/commands/skill-eval.js +289 -0
  63. package/commands/skill.js +56 -6
  64. package/commands/social.js +603 -0
  65. package/commands/spaceship.js +69 -6
  66. package/commands/stream.js +8 -1
  67. package/commands/study.js +32 -19
  68. package/commands/sync.js +28 -22
  69. package/commands/task.js +418 -60
  70. package/commands/teach.js +72 -63
  71. package/commands/terminal.js +25 -9
  72. package/commands/topup.js +88 -0
  73. package/commands/truth.js +52 -6
  74. package/commands/usage.js +91 -0
  75. package/commands/verify.js +3 -2
  76. package/commands/version.js +3 -1
  77. package/commands/voice.js +5 -1
  78. package/commands/who.js +69 -11
  79. package/commands/wiki.js +7 -4
  80. package/commands/wish.js +76 -5
  81. package/commands/workflow.js +327 -145
  82. package/commands/worktree.js +1 -0
  83. package/commands/write.js +5 -0
  84. package/commands/x-search.js +340 -0
  85. package/commands/xp.js +5 -1
  86. package/commands/youtube.js +551 -25
  87. package/lib/account-bound.js +47 -0
  88. package/lib/auto-accept-certified.js +8 -1
  89. package/lib/auto-lessons.js +186 -0
  90. package/lib/autoland.js +4 -2
  91. package/lib/chat-log-scan.js +7 -4
  92. package/lib/cli-json.js +77 -0
  93. package/lib/cli-scope.js +26 -0
  94. package/lib/codex-flight.js +1 -0
  95. package/lib/conductor-artifacts.js +1 -1
  96. package/lib/context-gatherer.js +11 -0
  97. package/lib/developer-api.js +116 -0
  98. package/lib/feedback-triage.js +274 -0
  99. package/lib/first-minute.js +398 -0
  100. package/lib/fleet.js +115 -34
  101. package/lib/functional-owner.js +22 -0
  102. package/lib/known-commands.js +31 -5
  103. package/lib/member-scaffold.js +197 -0
  104. package/lib/mission-root.js +4 -2
  105. package/lib/noninteractive.js +88 -0
  106. package/lib/policy-lessons.js +4 -1
  107. package/lib/revision-metric.js +279 -0
  108. package/lib/scratch-root.js +48 -0
  109. package/lib/skill-eval-gate.js +249 -0
  110. package/lib/task-db.js +4 -0
  111. package/lib/task-proof.js +69 -8
  112. package/lib/verifier-quality.js +69 -0
  113. package/lib/wish-audit.js +2 -2
  114. package/lib/wish-delegate.js +10 -1
  115. package/lib/workspace-scaffold.js +270 -0
  116. package/package.json +3 -2
  117. package/scripts/det/checklist-score.js +191 -0
  118. package/scripts/det/ytsearch +31 -0
  119. package/utils/update-check.js +15 -0
@@ -0,0 +1,197 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const MEMBER_DIRS = Object.freeze(['skills', 'tools', 'context', 'logs']);
5
+
6
+ function dateStamp(now = new Date()) {
7
+ return [
8
+ now.getFullYear(),
9
+ String(now.getMonth() + 1).padStart(2, '0'),
10
+ String(now.getDate()).padStart(2, '0'),
11
+ ].join('-');
12
+ }
13
+
14
+ function writeIfMissing(filePath, content) {
15
+ if (fs.existsSync(filePath)) return false;
16
+ fs.writeFileSync(filePath, content, { encoding: 'utf8', flag: 'wx' });
17
+ return true;
18
+ }
19
+
20
+ function memberMarkdown({ name, role, description }) {
21
+ const title = role || 'member';
22
+ const mission = description || 'Define why this member exists and how it chooses goals.';
23
+ return `---
24
+ name: ${name}
25
+ role: ${title}
26
+ description: ${mission}
27
+ version: 1.0.0
28
+
29
+ skills: []
30
+
31
+ permissions:
32
+ can-read: true
33
+ can-execute: true
34
+ can-approve: false
35
+ can-accept-task: false
36
+ approval-required: []
37
+
38
+ tools: []
39
+ ---
40
+
41
+ # ${title}
42
+
43
+ ## Mission
44
+
45
+ ${mission}
46
+
47
+ ## Persona
48
+
49
+ (Define how this member communicates, their tone, and decision-making style)
50
+
51
+ ## Workflow
52
+
53
+ 1. Understand the request.
54
+ 2. Do one bounded useful step.
55
+ 3. Leave proof another teammate can trust.
56
+
57
+ ## Rules
58
+
59
+ 1. Stay inside this member's role and proof standard.
60
+ 2. Ask when vision, taste, risk, or uncertainty matters.
61
+ `;
62
+ }
63
+
64
+ function memberSoulMarkdown({ name, role, born }) {
65
+ return `---
66
+ name: ${name}
67
+ version: 1.0.0
68
+ born: ${born}
69
+ ---
70
+
71
+ # Soul
72
+
73
+ This is who ${name} is when the rules run out.
74
+
75
+ ## Beliefs
76
+
77
+ - Useful work leaves evidence another teammate can trust.
78
+ - Clear limits produce better judgment than vague autonomy.
79
+ - The mission matters more than preserving a first idea.
80
+
81
+ ## Values
82
+
83
+ 1. Truth before confidence.
84
+ 2. Useful progress before activity.
85
+ 3. Continuity before cleverness.
86
+
87
+ ## Lessons
88
+
89
+ - Empty until this member earns lessons through experience.
90
+
91
+ ## Edges
92
+
93
+ - **Strong:** Focused judgment within the ${role} role.
94
+ - **Weak:** Must ask when work crosses its stated authority or proof standard.
95
+
96
+ ## Voice
97
+
98
+ - Understand the real need, make one grounded move, and leave the trail clearer.
99
+ `;
100
+ }
101
+
102
+ function memberMissionMarkdown({ name, role, description }) {
103
+ const purpose = description || `Handle ${role.toLowerCase()} work for the team.`;
104
+ return `# Mission
105
+
106
+ <!-- Human-authored purpose file. Keep this durable; runtime state belongs in .atris/state/*.jsonl and now.md. -->
107
+
108
+ ## North Star
109
+
110
+ ${purpose}
111
+
112
+ ## How To Choose Goals
113
+
114
+ - Use MEMBER.md to stay inside ${role}'s identity, authority, and tools.
115
+ - Choose one useful bounded goal toward this mission.
116
+ - Verify the work, write the receipt, and update the log.
117
+ - Ask the human when vision, taste, risk, or uncertainty matters.
118
+ `;
119
+ }
120
+
121
+ function emptyMemberGoals(name, now = new Date()) {
122
+ return {
123
+ schema: 'atris.member_goals.v1',
124
+ member: name,
125
+ updated_at: now.toISOString(),
126
+ goals: [],
127
+ };
128
+ }
129
+
130
+ function initialMemberLog({ name, role, description, source, now }) {
131
+ const stamp = now.toTimeString().slice(0, 5);
132
+ return [
133
+ `## ${stamp} · Member initialized`,
134
+ `- team: ${name}`,
135
+ `- role: ${role}`,
136
+ description ? `- mission: ${description}` : '',
137
+ `- source: ${source}`,
138
+ '- status: ready_for_room',
139
+ '',
140
+ ].filter(Boolean).join('\n');
141
+ }
142
+
143
+ function ensureMemberBundle(memberDir, {
144
+ name = path.basename(memberDir),
145
+ role = 'member',
146
+ description = 'Define why this member exists and how it chooses goals.',
147
+ source = 'cli',
148
+ memberContent,
149
+ now = new Date(),
150
+ } = {}) {
151
+ fs.mkdirSync(memberDir, { recursive: true });
152
+ for (const dirName of MEMBER_DIRS) {
153
+ fs.mkdirSync(path.join(memberDir, dirName), { recursive: true });
154
+ }
155
+
156
+ const created = [];
157
+ const ensure = (relativePath, content) => {
158
+ const filePath = path.join(memberDir, relativePath);
159
+ if (writeIfMissing(filePath, content)) created.push(relativePath);
160
+ return filePath;
161
+ };
162
+
163
+ const memberFile = typeof memberContent === 'string'
164
+ ? memberContent
165
+ : memberMarkdown({ name, role, description });
166
+ ensure('MEMBER.md', memberFile);
167
+ ensure('SOUL.md', memberSoulMarkdown({ name, role, born: dateStamp(now) }));
168
+ ensure('MISSION.md', memberMissionMarkdown({ name, role, description }));
169
+ ensure('goals.json', `${JSON.stringify(emptyMemberGoals(name, now), null, 2)}\n`);
170
+ ensure('goals.md', '# Goals\n\n<!-- Generated from goals.json. Edit with `atris member goal/tick/review` when possible. -->\n\nNo goals yet.\n');
171
+ const logName = `logs/${dateStamp(now)}.md`;
172
+ const logPath = ensure(logName, initialMemberLog({ name, role, description, source, now }));
173
+
174
+ return { created, logPath };
175
+ }
176
+
177
+ function memberBundlePresent(memberDir) {
178
+ if (!memberDir) return false;
179
+ const files = ['MEMBER.md', 'SOUL.md', 'MISSION.md', 'goals.json', 'goals.md'];
180
+ for (const fileName of files) {
181
+ if (!fs.existsSync(path.join(memberDir, fileName))) return false;
182
+ }
183
+ for (const dirName of MEMBER_DIRS) {
184
+ try {
185
+ if (!fs.statSync(path.join(memberDir, dirName)).isDirectory()) return false;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+ return true;
191
+ }
192
+
193
+ module.exports = {
194
+ ensureMemberBundle,
195
+ memberBundlePresent,
196
+ memberMarkdown,
197
+ };
@@ -17,8 +17,10 @@ const { spawnSync } = require('child_process');
17
17
  // same root — previously mission used only the git toplevel, so a mission
18
18
  // started inside a bound sub-workspace split away from that workspace's task
19
19
  // state. A git worktree reports its own toplevel (its .git marker), which is
20
- // the correct per-worktree store, so --worktree missions are unaffected. If the
21
- // shared resolver can't load, fall back to the git toplevel, then to cwd.
20
+ // the correct per-worktree store, so --worktree missions are unaffected. Walk-up
21
+ // stops before a generic scratch root (/tmp, /private/tmp, /var/tmp), so an
22
+ // empty folder under /tmp does not inherit /tmp's mission book. If the shared
23
+ // resolver can't load, fall back to the git toplevel, then to cwd.
22
24
  function resolveWorkspaceRoot(cwd = process.cwd()) {
23
25
  try {
24
26
  const { workspaceRoot } = require('./task-db');
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared headless/agent guard. When true, commands must not open a prompt.
5
+ * Covers: ATRIS_NONINTERACTIVE / ATRIS_NO_INTERACTIVE, --yes/-y, --json.
6
+ * Non-TTY stdin alone is usually non-interactive, but callers that accept
7
+ * piped batch input (like `atris log`) should use isForcedNonInteractive
8
+ * and handle empty vs piped stdin themselves.
9
+ */
10
+ function isForcedNonInteractive(args = process.argv.slice(2)) {
11
+ const list = Array.isArray(args) ? args : [];
12
+ if (process.env.ATRIS_NONINTERACTIVE === '1') return true;
13
+ if (process.env.ATRIS_NO_INTERACTIVE === '1') return true;
14
+ if (list.includes('--yes') || list.includes('-y')) return true;
15
+ if (list.includes('--json')) return true;
16
+ return false;
17
+ }
18
+
19
+ function isNonInteractive(args = process.argv.slice(2)) {
20
+ if (isForcedNonInteractive(args)) return true;
21
+ if (!process.stdin.isTTY) return true;
22
+ return false;
23
+ }
24
+
25
+ function wantsJson(args = process.argv.slice(2)) {
26
+ return Array.isArray(args) && args.includes('--json');
27
+ }
28
+
29
+ function isHelpToken(arg) {
30
+ return arg === '--help' || arg === '-h' || arg === 'help' || arg === '-?';
31
+ }
32
+
33
+ function argsWantHelp(args = []) {
34
+ const list = Array.isArray(args) ? args : [];
35
+ return list.some(isHelpToken);
36
+ }
37
+
38
+ function hasYesFlag(args = []) {
39
+ const list = Array.isArray(args) ? args : [];
40
+ return list.includes('--yes') || list.includes('-y');
41
+ }
42
+
43
+ /**
44
+ * Headless / agent sessions must not hang on interactive runners.
45
+ * Non-TTY, ATRIS_NONINTERACTIVE=1, and ATRIS_NO_INTERACTIVE=1 all refuse
46
+ * unless the caller passed an explicit proceed flag (--yes, or --once for serve).
47
+ */
48
+ function refuseHeadlessUnless(args, { allowYes = true, allowOnce = false, usage = '' } = {}) {
49
+ const list = Array.isArray(args) ? args : [];
50
+ const headless = Boolean(
51
+ process.env.ATRIS_NONINTERACTIVE === '1'
52
+ || process.env.ATRIS_NO_INTERACTIVE === '1'
53
+ || !process.stdin.isTTY
54
+ || !process.stdout.isTTY
55
+ );
56
+ if (!headless) return false;
57
+ if (allowYes && hasYesFlag(list)) return false;
58
+ if (allowOnce && list.includes('--once')) return false;
59
+ const lines = [];
60
+ if (usage) lines.push(usage);
61
+ else lines.push('This command needs a terminal, or an explicit proceed flag.');
62
+ lines.push('Pass --yes to run headless' + (allowOnce ? ', or --once for a single serve op.' : '.'));
63
+ console.error(lines.join('\n'));
64
+ return true;
65
+ }
66
+
67
+ function rejectUnsupportedJson(commandName, args = process.argv.slice(2)) {
68
+ if (!wantsJson(args)) return false;
69
+ console.log(JSON.stringify({
70
+ ok: false,
71
+ error: `${commandName} does not support --json`,
72
+ command: commandName,
73
+ usage: `atris ${commandName}`,
74
+ }, null, 2));
75
+ process.exit(2);
76
+ return true;
77
+ }
78
+
79
+ module.exports = {
80
+ isForcedNonInteractive,
81
+ isNonInteractive,
82
+ wantsJson,
83
+ isHelpToken,
84
+ argsWantHelp,
85
+ hasYesFlag,
86
+ refuseHeadlessUnless,
87
+ rejectUnsupportedJson,
88
+ };
@@ -9,6 +9,7 @@
9
9
  // differently than the last. No LLM calls; re-mining refreshes the numbers.
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { fileAutoLessons } = require('./auto-lessons');
12
13
  const { RECEIPT_PATH_PATTERN, extractReceiptEvidence } = require('./receipt-evidence');
13
14
  const escapeRegExp = require('./escape-regexp');
14
15
 
@@ -41,11 +42,13 @@ function readJsonlFile(filePath) {
41
42
  }
42
43
 
43
44
  function loadHistory(root) {
44
- return {
45
+ const history = {
45
46
  receipts: readJsonlFile(path.join(root, CAREER_XP_RECEIPTS_FILE)),
46
47
  episodes: readJsonlFile(path.join(root, TASK_EPISODES_FILE)),
47
48
  scorecards: readJsonlFile(path.join(root, SCORECARDS_FILE)),
48
49
  };
50
+ fileAutoLessons(root, { episodes: history.episodes });
51
+ return history;
49
52
  }
50
53
 
51
54
  function isAgentActor(actor) {
@@ -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
+ };
@@ -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
+ };