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.
- 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/x-search/SKILL.md +24 -22
- package/atris/skills/youtube/SKILL.md +82 -33
- package/atris/team/_template/SOUL.md +40 -0
- package/atris/team/validator/MEMBER.md +1 -2
- package/bin/atris +21 -0
- package/bin/atris.js +563 -373
- 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/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 +287 -50
- package/commands/now.js +15 -3
- 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 +31 -7
- 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 -25
- package/lib/account-bound.js +47 -0
- package/lib/auto-accept-certified.js +8 -1
- 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/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-root.js +4 -2
- package/lib/noninteractive.js +88 -0
- package/lib/policy-lessons.js +4 -1
- package/lib/revision-metric.js +279 -0
- package/lib/scratch-root.js +48 -0
- package/lib/skill-eval-gate.js +249 -0
- package/lib/task-db.js +4 -0
- package/lib/task-proof.js +69 -8
- 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 +3 -2
- package/scripts/det/checklist-score.js +191 -0
- package/scripts/det/ytsearch +31 -0
- 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
|
+
};
|
package/lib/mission-root.js
CHANGED
|
@@ -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.
|
|
21
|
-
//
|
|
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
|
+
};
|
package/lib/policy-lessons.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|