atris 3.26.0 → 3.28.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/AGENTS.md +1 -1
- package/README.md +7 -5
- package/atris/features/company-brain-sync/build.md +4 -4
- package/atris/features/company-brain-sync/idea.md +2 -2
- package/atris/features/company-brain-sync/validate.md +19 -19
- package/atris/skills/aeo/SKILL.md +1 -1
- package/atris/skills/autopilot/SKILL.md +2 -2
- package/atris/skills/slides/SKILL.md +2 -2
- package/bin/atris.js +32 -14
- package/commands/activate.js +24 -0
- package/commands/aeo.js +1 -1
- package/commands/autopilot.js +1 -1
- package/commands/clarity.js +125 -0
- package/commands/computer.js +9 -9
- package/commands/feedback.js +1 -1
- package/commands/init.js +2 -2
- package/commands/live.js +3 -3
- package/commands/moves.js +156 -0
- package/commands/plugin.js +1 -1
- package/commands/pull.js +4 -4
- package/commands/security-review.js +132 -0
- package/commands/signup.js +101 -0
- package/commands/task.js +0 -1
- package/lib/clarity.js +97 -0
- package/lib/next-moves.js +362 -0
- package/lib/security-scan.js +188 -0
- package/package.json +1 -8
- package/utils/update-check.js +77 -24
- package/atris/wiki/concepts/agent-activation-contract.md +0 -81
- package/atris/wiki/concepts/workspace-initialization-contract.md +0 -73
- package/atris/wiki/index.md +0 -31
- package/atris/wiki/sources/atris-labs-2026-05-10.txt +0 -14
- package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +0 -14
- package/atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt +0 -10
- package/atris/wiki/sources/jack-dorsey-2026-05-10.txt +0 -12
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Alive onboarding: the engine behind `atris moves`.
|
|
4
|
+
//
|
|
5
|
+
// It reads the workspace for the highest-leverage next moves (the goal in
|
|
6
|
+
// ROADMAP.md, work already in flight, fresh inbox ideas), ranks them, and lets
|
|
7
|
+
// the human approve, kill, or skip. Approved moves are seeded into today's
|
|
8
|
+
// inbox, which the loop's hasWork() already reads, so onboarding feeds the
|
|
9
|
+
// loop without touching the autonomous core.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function safeRead(p) {
|
|
15
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Normalize a title for dedup and suppression, the same way moveId does, so a
|
|
19
|
+
// move matches itself across case and whitespace.
|
|
20
|
+
function norm(title) {
|
|
21
|
+
return String(title == null ? '' : title).trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Short, stable id so a kill on Tuesday still suppresses the same move on
|
|
25
|
+
// Wednesday. Pure function of (source, title).
|
|
26
|
+
function moveId(source, title) {
|
|
27
|
+
const s = `${source}:${String(title).trim().toLowerCase()}`;
|
|
28
|
+
let h = 2166136261;
|
|
29
|
+
for (let i = 0; i < s.length; i++) {
|
|
30
|
+
h ^= s.charCodeAt(i);
|
|
31
|
+
h = Math.imul(h, 16777619);
|
|
32
|
+
}
|
|
33
|
+
return `m_${(h >>> 0).toString(36)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WEIGHT = { roadmap: 100, task: 60, inbox: 40 };
|
|
37
|
+
|
|
38
|
+
// One section-boundary shape for every reader and writer: tolerate a header
|
|
39
|
+
// suffix (e.g. "(priority)"), stop at a sibling/parent heading (## or #) or a
|
|
40
|
+
// malformed "##Next", but NOT at a child ### inside the section. Keeping a single
|
|
41
|
+
// definition is the whole point: divergent boundaries are how the gate and the
|
|
42
|
+
// picker drift apart.
|
|
43
|
+
const SECTION_TAIL = '\\b[^\\n]*\\r?\\n([\\s\\S]*?)(?=\\r?\\n##[^#]|\\r?\\n# |$)';
|
|
44
|
+
const OPEN_ITEMS_RE = new RegExp(`##\\s+Open loop items${SECTION_TAIL}`, 'i');
|
|
45
|
+
const INBOX_RE = new RegExp(`##\\s+Inbox${SECTION_TAIL}`, 'i');
|
|
46
|
+
|
|
47
|
+
// Locate a section's body and its character span, so a writer can splice an edit
|
|
48
|
+
// back into exactly the region a reader parsed.
|
|
49
|
+
function findSection(text, re) {
|
|
50
|
+
const m = text.match(re);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const bodyStart = m.index + m[0].length - m[1].length;
|
|
53
|
+
return { body: m[1], start: bodyStart, end: bodyStart + m[1].length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Clean inbox titles out of a journal's ## Inbox section. The one inbox parser,
|
|
57
|
+
// shared by latestInboxItems, todayInboxItems, and the run.js work gate.
|
|
58
|
+
function parseInboxTitles(text) {
|
|
59
|
+
const m = (text || '').match(INBOX_RE);
|
|
60
|
+
if (!m) return [];
|
|
61
|
+
return m[1]
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.map((l) => l.trim())
|
|
64
|
+
.filter((l) => l.startsWith('- ') && l.length > 2)
|
|
65
|
+
.map((l) => l.replace(/^-\s*\*\*[IC]?\d*:?\*\*\s*/, '').replace(/^-\s*\*\*/, '').replace(/\*\*$/, '').replace(/^-\s*/, '').trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readRoadmapOpenItems(root) {
|
|
70
|
+
const text = safeRead(path.join(root, 'ROADMAP.md'));
|
|
71
|
+
if (!text) return [];
|
|
72
|
+
const section = findSection(text, OPEN_ITEMS_RE);
|
|
73
|
+
if (!section) return [];
|
|
74
|
+
return section.body
|
|
75
|
+
.split(/\r?\n/)
|
|
76
|
+
.map((l) => l.trim())
|
|
77
|
+
.filter((l) => /^- \[ \]/.test(l))
|
|
78
|
+
.map((l) => l.replace(/^- \[ \]\s*/, '').trim())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.map((title) => ({
|
|
81
|
+
title,
|
|
82
|
+
why: 'open item in ROADMAP.md, the goal the loop pursues',
|
|
83
|
+
source: 'roadmap',
|
|
84
|
+
weight: WEIGHT.roadmap,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readActiveTasks(root) {
|
|
89
|
+
const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
|
|
90
|
+
if (!text) return [];
|
|
91
|
+
let proj;
|
|
92
|
+
try { proj = JSON.parse(text); } catch { return []; }
|
|
93
|
+
const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
94
|
+
return tasks
|
|
95
|
+
.filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
|
|
96
|
+
.map((t) => ({
|
|
97
|
+
title: String(t.title).trim(),
|
|
98
|
+
why: `task in flight (${t.status}${t.claimed_by ? `, ${t.claimed_by}` : ''})`,
|
|
99
|
+
source: 'task',
|
|
100
|
+
ref: t.display_id || t.id || null,
|
|
101
|
+
weight: WEIGHT.task,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inboxItemsFrom(text) {
|
|
106
|
+
return parseInboxTitles(text).map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Most recent journal under atris/logs/YYYY/, parsed for ## Inbox items. Used to
|
|
110
|
+
// SURFACE ideas in `atris moves`.
|
|
111
|
+
function latestInboxItems(root) {
|
|
112
|
+
const logsDir = path.join(root, 'atris', 'logs');
|
|
113
|
+
let years;
|
|
114
|
+
try { years = fs.readdirSync(logsDir).filter((d) => /^\d{4}$/.test(d)).sort(); } catch { return []; }
|
|
115
|
+
if (!years.length) return [];
|
|
116
|
+
const yearDir = path.join(logsDir, years[years.length - 1]);
|
|
117
|
+
let files;
|
|
118
|
+
try { files = fs.readdirSync(yearDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort(); } catch { return []; }
|
|
119
|
+
if (!files.length) return [];
|
|
120
|
+
return inboxItemsFrom(safeRead(path.join(yearDir, files[files.length - 1])));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// TODAY's journal inbox only. The loop's work gate and the seed-suppression must
|
|
124
|
+
// read the SAME file the seeder writes (today's), or a prior-day duplicate makes
|
|
125
|
+
// them disagree. Returns [] if today's file is absent.
|
|
126
|
+
function todayInboxItems(root) {
|
|
127
|
+
const { file } = todayLogFile(root);
|
|
128
|
+
if (!fs.existsSync(file)) return [];
|
|
129
|
+
return inboxItemsFrom(safeRead(file));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function gatherCandidates(root = process.cwd()) {
|
|
133
|
+
return [
|
|
134
|
+
...readRoadmapOpenItems(root),
|
|
135
|
+
...readActiveTasks(root),
|
|
136
|
+
...latestInboxItems(root),
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pure ranking: drop killed/approved, sort by weight, then dedupe by title
|
|
141
|
+
// (keeping the highest-weight copy), take `limit`.
|
|
142
|
+
//
|
|
143
|
+
// Suppression: the exact move the operator acted on is dropped by id. Titles are
|
|
144
|
+
// only suppressed for IDEAS (roadmap/inbox), never for a real `task`, so killing
|
|
145
|
+
// or approving an idea can't hide a genuine in-flight task that happens to share
|
|
146
|
+
// the title.
|
|
147
|
+
function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [], approvedIds = [], approvedTitles = [] } = {}) {
|
|
148
|
+
const blockedIds = new Set([...killedIds, ...approvedIds]);
|
|
149
|
+
const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(norm));
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
return candidates
|
|
152
|
+
.map((c) => ({ ...c, id: moveId(c.source, c.title), _key: norm(c.title) }))
|
|
153
|
+
.filter((c) => {
|
|
154
|
+
if (!c._key) return false;
|
|
155
|
+
if (blockedIds.has(c.id)) return false;
|
|
156
|
+
if (c.source !== 'task' && blockedTitles.has(c._key)) return false;
|
|
157
|
+
return true;
|
|
158
|
+
})
|
|
159
|
+
.sort((a, b) => (b.weight || 0) - (a.weight || 0))
|
|
160
|
+
.filter((c) => {
|
|
161
|
+
if (seen.has(c._key)) return false;
|
|
162
|
+
seen.add(c._key);
|
|
163
|
+
return true;
|
|
164
|
+
})
|
|
165
|
+
.map(({ _key, ...c }) => c)
|
|
166
|
+
.slice(0, limit);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const DECISIONS_FILE = ['.atris', 'state', 'moves.decisions.jsonl'];
|
|
170
|
+
|
|
171
|
+
function decisionsPath(root) {
|
|
172
|
+
return path.join(root, ...DECISIONS_FILE);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readDecisions(root = process.cwd()) {
|
|
176
|
+
const text = safeRead(decisionsPath(root));
|
|
177
|
+
const rows = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
|
178
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
const idsFor = (decision) => rows.filter((r) => r.decision === decision).map((r) => r.id);
|
|
181
|
+
const titlesFor = (decision) => rows.filter((r) => r.decision === decision).map((r) => norm(r.title));
|
|
182
|
+
return {
|
|
183
|
+
killedIds: idsFor('kill'),
|
|
184
|
+
killedTitles: titlesFor('kill'),
|
|
185
|
+
approvedIds: idsFor('approve'),
|
|
186
|
+
approvedTitles: titlesFor('approve'),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function recordDecision(root, move, decision, stamp) {
|
|
191
|
+
const p = decisionsPath(root);
|
|
192
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
193
|
+
const row = { id: move.id, title: move.title, source: move.source, decision, at: stamp || null };
|
|
194
|
+
fs.appendFileSync(p, `${JSON.stringify(row)}\n`, 'utf8');
|
|
195
|
+
return row;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function todayLogFile(root, date = new Date()) {
|
|
199
|
+
const y = date.getFullYear();
|
|
200
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
201
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
202
|
+
const dateFormatted = `${y}-${m}-${d}`;
|
|
203
|
+
return { file: path.join(root, 'atris', 'logs', String(y), `${dateFormatted}.md`), dateFormatted };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The canonical day-journal sections every reader expects (status, analytics,
|
|
207
|
+
// section parsers). Mirrors lib/journal.js createLogFile, minus the em dash in
|
|
208
|
+
// its title, so a journal first created by the idle-seed path is not malformed.
|
|
209
|
+
function canonicalJournal(dateFormatted) {
|
|
210
|
+
return `# Log ${dateFormatted}\n\n## Handoff\n\n---\n\n## Completed ✅\n\n---\n\n## In Progress 🔄\n\n---\n\n## Backlog\n\n---\n\n## Notes\n\n---\n\n## Inbox\n\n`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Seed an approved move into today's inbox so the loop's hasWork() finds it.
|
|
214
|
+
// Idempotent by title: if the same title is already in today's inbox, it is not
|
|
215
|
+
// appended again (so a retry or a concurrent cycle cannot duplicate it).
|
|
216
|
+
function seedInboxFromMove(root, move, date) {
|
|
217
|
+
const { file, dateFormatted } = todayLogFile(root, date);
|
|
218
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
219
|
+
let content = safeRead(file);
|
|
220
|
+
if (!content) {
|
|
221
|
+
content = canonicalJournal(dateFormatted);
|
|
222
|
+
}
|
|
223
|
+
if (!/##\s+Inbox/i.test(content)) {
|
|
224
|
+
content = content.replace(/^(#.*\n)/, `$1\n## Inbox\n`);
|
|
225
|
+
if (!/##\s+Inbox/i.test(content)) content += `\n## Inbox\n`;
|
|
226
|
+
}
|
|
227
|
+
const target = norm(move.title);
|
|
228
|
+
if (parseInboxTitles(content).some((t) => norm(t) === target)) {
|
|
229
|
+
return { file, line: null, nextId: null, alreadyPresent: true };
|
|
230
|
+
}
|
|
231
|
+
const existingIds = (content.match(/-\s*\*\*I(\d+):/g) || []).map((m2) => parseInt(m2.match(/I(\d+)/)[1], 10));
|
|
232
|
+
const nextId = existingIds.length ? Math.max(...existingIds) + 1 : 1;
|
|
233
|
+
const line = `- **I${nextId}:** ${move.title}`;
|
|
234
|
+
// Insert right after the Inbox header line only (do not consume blank lines or
|
|
235
|
+
// following sections). Use a function replacer so a title containing $ tokens
|
|
236
|
+
// ($1, $&, $$) is inserted literally, not interpreted as a replacement pattern.
|
|
237
|
+
content = content.replace(/(##\s+Inbox[^\n]*\r?\n)/i, (m) => `${m}${line}\n`);
|
|
238
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
239
|
+
return { file, line, nextId };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Claim an open ROADMAP item for the loop by flipping its `- [ ]` to `- [~]`
|
|
243
|
+
// (claimed, in flight) WITHIN the "## Open loop items" section only. This is the
|
|
244
|
+
// single source of truth for "the loop took this on": readRoadmapOpenItems only
|
|
245
|
+
// returns `- [ ]`, so a claimed item is excluded, and the work gate and the seed
|
|
246
|
+
// picker stay in agreement. Scoped to the section so a same-titled `- [ ]` in
|
|
247
|
+
// another section (Big jobs, an example block) is never flipped by mistake.
|
|
248
|
+
// `- [~]` is distinct from human `- [x]` (done) so a reader is not misled.
|
|
249
|
+
function claimRoadmapItem(root, title) {
|
|
250
|
+
const p = path.join(root, 'ROADMAP.md');
|
|
251
|
+
const text = safeRead(p);
|
|
252
|
+
if (!text) return false;
|
|
253
|
+
const section = findSection(text, OPEN_ITEMS_RE);
|
|
254
|
+
if (!section) return false;
|
|
255
|
+
const target = norm(title);
|
|
256
|
+
let changed = false;
|
|
257
|
+
const newBody = section.body.split(/\r?\n/).map((line) => {
|
|
258
|
+
if (changed) return line;
|
|
259
|
+
const m = line.match(/^(\s*)- \[ \]\s*(.+?)\s*$/);
|
|
260
|
+
if (m && norm(m[2]) === target) {
|
|
261
|
+
changed = true;
|
|
262
|
+
return `${m[1]}- [~] ${m[2].trim()}`;
|
|
263
|
+
}
|
|
264
|
+
return line;
|
|
265
|
+
}).join('\n');
|
|
266
|
+
if (!changed) return false;
|
|
267
|
+
const out = text.slice(0, section.start) + newBody + text.slice(section.end);
|
|
268
|
+
fs.writeFileSync(p, out, 'utf8');
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Add one bounded item to the "## Open loop items" section so the loop (and
|
|
273
|
+
// `atris moves`) will pursue it: messy input straight into the working queue,
|
|
274
|
+
// no markdown editing. Creates ROADMAP.md and the section if missing. Dedups
|
|
275
|
+
// against existing OPEN items only (a done/claimed one can be re-added).
|
|
276
|
+
function addRoadmapItem(root, text) {
|
|
277
|
+
// Collapse any whitespace/newlines to single spaces (a multi-line title would
|
|
278
|
+
// break the markdown line) and strip a leading bullet/checkbox the user may
|
|
279
|
+
// have pasted, so the item is exactly one well-formed `- [ ]` line.
|
|
280
|
+
const title = String(text || '').replace(/\s+/g, ' ').trim().replace(/^- \[[ x~]\]\s*/, '').replace(/^- /, '').trim();
|
|
281
|
+
if (!title) return { added: false, reason: 'empty', title: null };
|
|
282
|
+
const p = path.join(root, 'ROADMAP.md');
|
|
283
|
+
let content = safeRead(p);
|
|
284
|
+
if (!content) content = '# Roadmap\n\n## Open loop items\n\n';
|
|
285
|
+
let section = findSection(content, OPEN_ITEMS_RE);
|
|
286
|
+
if (!section) {
|
|
287
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
288
|
+
content += '\n## Open loop items\n\n';
|
|
289
|
+
section = findSection(content, OPEN_ITEMS_RE);
|
|
290
|
+
}
|
|
291
|
+
const openTitles = section.body.split(/\r?\n/)
|
|
292
|
+
.map((l) => l.trim())
|
|
293
|
+
.filter((l) => /^- \[ \]/.test(l))
|
|
294
|
+
.map((l) => norm(l.replace(/^- \[ \]\s*/, '')));
|
|
295
|
+
if (openTitles.includes(norm(title))) return { added: false, reason: 'already an open item', title };
|
|
296
|
+
// Insert at the top of the section body so the loop pulls it next.
|
|
297
|
+
const out = `${content.slice(0, section.start)}\n- [ ] ${title}\n${content.slice(section.start)}`;
|
|
298
|
+
fs.writeFileSync(p, out, 'utf8');
|
|
299
|
+
return { added: true, title };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Group the Open loop items by state: queued `- [ ]`, in-flight `- [~]` (claimed
|
|
303
|
+
// by the loop), done `- [x]`. The evidence for a loop report.
|
|
304
|
+
function roadmapItemsByState(root) {
|
|
305
|
+
const out = { open: [], claimed: [], done: [] };
|
|
306
|
+
const text = safeRead(path.join(root, 'ROADMAP.md'));
|
|
307
|
+
if (!text) return out;
|
|
308
|
+
const section = findSection(text, OPEN_ITEMS_RE);
|
|
309
|
+
if (!section) return out;
|
|
310
|
+
for (const raw of section.body.split(/\r?\n/)) {
|
|
311
|
+
const l = raw.trim();
|
|
312
|
+
let m;
|
|
313
|
+
if ((m = l.match(/^- \[ \]\s*(.+)$/))) out.open.push(m[1].trim());
|
|
314
|
+
else if ((m = l.match(/^- \[~\]\s*(.+)$/))) out.claimed.push(m[1].trim());
|
|
315
|
+
else if ((m = l.match(/^- \[x\]\s*(.+)$/))) out.done.push(m[1].trim());
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The top open ROADMAP item the loop has not already handled. Claimed items are
|
|
321
|
+
// marked `- [~]` (so readRoadmapOpenItems drops them); this also skips anything
|
|
322
|
+
// already in TODAY's inbox or killed/approved, so an idle loop advances to the
|
|
323
|
+
// next item each cycle. todayInboxItems (not latestInboxItems) so the picker, the
|
|
324
|
+
// work gate, and the seeder all read the same file. Root-explicit and pure of
|
|
325
|
+
// cwd, so it is testable without a live runner.
|
|
326
|
+
function pickRoadmapSeed(root = process.cwd()) {
|
|
327
|
+
const items = readRoadmapOpenItems(root);
|
|
328
|
+
if (!items.length) return null;
|
|
329
|
+
const inbox = todayInboxItems(root).map((i) => norm(i.title));
|
|
330
|
+
const { killedTitles, approvedTitles } = readDecisions(root);
|
|
331
|
+
const blocked = new Set([...inbox, ...killedTitles, ...approvedTitles]);
|
|
332
|
+
return items.find((it) => !blocked.has(norm(it.title))) || null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Shared "3 next moves" recipe used by both `atris moves` and `atris activate`,
|
|
336
|
+
// so the ranking inputs live in one place.
|
|
337
|
+
function nextMoves(root = process.cwd(), limit = 3) {
|
|
338
|
+
const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
|
|
339
|
+
return pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = {
|
|
343
|
+
moveId,
|
|
344
|
+
norm,
|
|
345
|
+
WEIGHT,
|
|
346
|
+
parseInboxTitles,
|
|
347
|
+
readRoadmapOpenItems,
|
|
348
|
+
readActiveTasks,
|
|
349
|
+
latestInboxItems,
|
|
350
|
+
todayInboxItems,
|
|
351
|
+
gatherCandidates,
|
|
352
|
+
pickNextMoves,
|
|
353
|
+
nextMoves,
|
|
354
|
+
readDecisions,
|
|
355
|
+
recordDecision,
|
|
356
|
+
seedInboxFromMove,
|
|
357
|
+
pickRoadmapSeed,
|
|
358
|
+
claimRoadmapItem,
|
|
359
|
+
addRoadmapItem,
|
|
360
|
+
roadmapItemsByState,
|
|
361
|
+
todayLogFile,
|
|
362
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris security scan — deterministic secrets / PII / privacy detectors (no LLM).
|
|
4
|
+
//
|
|
5
|
+
// A finding is a fact (file:line + rule + severity), so it drops straight into a
|
|
6
|
+
// loop / mission / CI gate and doubles as a SOC 2 evidence artifact (machine
|
|
7
|
+
// JSON). Precision over recall: a noisy gate gets muted, and a muted gate is dead.
|
|
8
|
+
// Suppress a single line with a trailing `atris-allow-secret` comment.
|
|
9
|
+
//
|
|
10
|
+
// Zero external deps (Node built-ins only) — repo contract.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const SCAN_EXTS = new Set([
|
|
17
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.md', '.mdx', '.txt',
|
|
18
|
+
'.yml', '.yaml', '.env', '.sh', '.bash', '.zsh', '.py', '.rb', '.go', '.java',
|
|
19
|
+
'.php', '.toml', '.ini', '.cfg', '.conf', '.html', '.vue', '.svelte', '.patch',
|
|
20
|
+
]);
|
|
21
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'out', 'vendor', '.cache', '__pycache__']);
|
|
22
|
+
|
|
23
|
+
// Lines that opt out, and placeholder noise we never flag.
|
|
24
|
+
const ALLOW_MARKER = /atris-allow-secret|atris-security-ignore/i;
|
|
25
|
+
const PLACEHOLDER = /\b(?:example|placeholder|your[_-]?|my[_-]?key|xxx+|redacted|dummy|sample|changeme|test[_-]?key|fake|dummy|<[a-z_-]+>|process\.env|getenv|os\.environ|import\.meta\.env)\b/i;
|
|
26
|
+
|
|
27
|
+
// High-precision secret patterns. severity high => fails the gate.
|
|
28
|
+
const SECRET_RULES = [
|
|
29
|
+
{ id: 'private-key', sev: 'high', cat: 'secret', re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA |ENCRYPTED )?PRIVATE KEY-----/, why: 'private key committed in source' },
|
|
30
|
+
{ id: 'aws-access-key-id', sev: 'high', cat: 'secret', re: /\bAKIA[0-9A-Z]{16}\b/, why: 'AWS access key id' },
|
|
31
|
+
{ id: 'github-token', sev: 'high', cat: 'secret', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, why: 'GitHub personal/OAuth token' },
|
|
32
|
+
{ id: 'slack-token', sev: 'high', cat: 'secret', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, why: 'Slack token' },
|
|
33
|
+
{ id: 'slack-webhook', sev: 'high', cat: 'secret', re: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{20,}/, why: 'Slack incoming webhook url' },
|
|
34
|
+
{ id: 'openai-key', sev: 'high', cat: 'secret', re: /\bsk-(?:proj-)?[A-Za-z0-9]{20,}\b/, why: 'OpenAI-style API key' },
|
|
35
|
+
{ id: 'anthropic-key', sev: 'high', cat: 'secret', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, why: 'Anthropic API key' },
|
|
36
|
+
{ id: 'google-api-key', sev: 'high', cat: 'secret', re: /\bAIza[0-9A-Za-z_-]{35}\b/, why: 'Google API key' },
|
|
37
|
+
{ id: 'stripe-key', sev: 'high', cat: 'secret', re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/, why: 'Stripe live key' },
|
|
38
|
+
{ id: 'npm-token', sev: 'high', cat: 'secret', re: /\bnpm_[A-Za-z0-9]{36}\b/, why: 'npm access token' },
|
|
39
|
+
{ id: 'jwt', sev: 'medium', cat: 'secret', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}\b/, why: 'JWT (often carries claims/PII)' },
|
|
40
|
+
{ id: 'bearer-token', sev: 'medium', cat: 'secret', re: /\bBearer\s+[A-Za-z0-9._-]{24,}/, why: 'hardcoded Bearer token' },
|
|
41
|
+
{ id: 'assigned-secret', sev: 'high', cat: 'secret', re: /(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)\s*[:=]\s*['"][^'"\s]{8,}['"]/i, why: 'hardcoded credential assignment' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Personal data. Home-path is the recurring leak (a username + local layout).
|
|
45
|
+
const PII_RULES = [
|
|
46
|
+
{ id: 'home-path', sev: 'medium', cat: 'pii', re: /(?:\/Users\/|\/home\/)(?!runner\/|runner\b|root\/|root\b|ubuntu\/|ubuntu\b|user\/|user\b|node\/)[a-z][a-z0-9_.-]+/i, why: 'personal home path leaks a username + local layout (use os.homedir()/relative)' },
|
|
47
|
+
{ id: 'windows-home-path', sev: 'medium', cat: 'pii', re: /[A-Za-z]:\\Users\\(?!Public\b)[^\\\s'"]+/, why: 'personal Windows path leaks a username' },
|
|
48
|
+
{ id: 'email', sev: 'low', cat: 'pii', re: /\b[A-Za-z0-9._%+-]+@(?!example\.|sentry\.io|test\b|localhost|[\w.-]*\.local\b|sub\.)[A-Za-z0-9.-]+\.(?:com|net|org|ai|io|dev|co)\b/, why: 'email address (possible PII)' },
|
|
49
|
+
{ id: 'phone', sev: 'low', cat: 'pii', re: /(?<![\d.\w])(?:\+?1[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}(?![\d.\w])/, why: 'phone-number-shaped string' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Code-execution risks. For an AI CLI that runs autonomous loops and shells out,
|
|
53
|
+
// these are the "are we actually safe" checks beyond data exposure. eval/Function
|
|
54
|
+
// are almost never legitimate (HIGH); shelling out with interpolated input is the
|
|
55
|
+
// command-injection class (MEDIUM — common and sometimes safe, so it asks for a
|
|
56
|
+
// human look rather than hard-failing the gate).
|
|
57
|
+
const CODE_RULES = [
|
|
58
|
+
{ id: 'eval-call', sev: 'high', cat: 'code', re: /(?<![.\w])eval\((?!\s*\))/, why: 'eval() executes arbitrary code' },
|
|
59
|
+
{ id: 'new-function', sev: 'high', cat: 'code', re: /\bnew\s+Function\(/, why: 'new Function() executes arbitrary code' },
|
|
60
|
+
{ id: 'shell-exec-interpolation', sev: 'medium', cat: 'code', re: /\b(?:exec|execSync)\s*\(\s*[`'"][^`'"]*(?:\$\{|"\s*\+|'\s*\+)/, why: 'shell exec with interpolated input (command-injection risk; prefer execFile/spawn with an args array)' },
|
|
61
|
+
{ id: 'child-process-shell-true', sev: 'medium', cat: 'code', re: /\bshell\s*:\s*true\b/, why: 'child_process shell:true enables shell interpretation of args' },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// extra per-line excludes that keep email/path rules from firing on safe noise
|
|
65
|
+
const EMAIL_SAFE = /\b(?:noreply|no-reply|support|hello|info|contact|team|hi|admin|press)@/i;
|
|
66
|
+
|
|
67
|
+
const RULES = [...SECRET_RULES, ...PII_RULES, ...CODE_RULES];
|
|
68
|
+
|
|
69
|
+
// Filenames that should never be committed (checked against the tracked file list).
|
|
70
|
+
const SENSITIVE_FILE_RE = /(?:^|\/)(?:\.env(?:\.[\w.-]+)?|id_rsa|id_dsa|id_ed25519|.*\.pem|.*\.pfx|.*\.p12|.*\.keystore|credentials\.json|\.npmrc|\.pypirc|\.netrc|secrets?\.(?:json|ya?ml|env))$/i;
|
|
71
|
+
const SENSITIVE_FILE_ALLOW = /\.env\.example$|\.env\.sample$|\.env\.template$/i;
|
|
72
|
+
|
|
73
|
+
function scanLine(line, rules = RULES) {
|
|
74
|
+
if (ALLOW_MARKER.test(line)) return [];
|
|
75
|
+
const findings = [];
|
|
76
|
+
const placeholder = PLACEHOLDER.test(line);
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
const m = rule.re.exec(line);
|
|
79
|
+
if (!m) continue;
|
|
80
|
+
// Secrets in obvious placeholder/env-read lines are not real leaks.
|
|
81
|
+
if (rule.cat === 'secret' && placeholder) continue;
|
|
82
|
+
if (rule.id === 'email' && EMAIL_SAFE.test(line)) continue;
|
|
83
|
+
findings.push({ rule: rule.id, sev: rule.sev, cat: rule.cat, why: rule.why, snippet: m[0].trim().slice(0, 60) });
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scanText(text, rules = RULES) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const lines = String(text || '').split('\n');
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
for (const f of scanLine(lines[i], rules)) out.push({ ...f, line: i + 1 });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Code-execution rules only make sense in actual code files — `eval(` written in
|
|
98
|
+
// a markdown doc is prose, not a vuln.
|
|
99
|
+
const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.java', '.php', '.sh', '.bash', '.zsh']);
|
|
100
|
+
|
|
101
|
+
function rulesForFile(file) {
|
|
102
|
+
return CODE_EXTS.has(path.extname(file)) ? RULES : RULES.filter((r) => r.cat !== 'code');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scanFile(file, rules) {
|
|
106
|
+
let text;
|
|
107
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
108
|
+
return scanText(text, rules || rulesForFile(file)).map((f) => ({ ...f, file }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Repo-level hygiene: a sensitive file being tracked at all is a finding,
|
|
112
|
+
// independent of its contents.
|
|
113
|
+
function sensitiveFileFindings(relPaths) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const p of relPaths) {
|
|
116
|
+
if (SENSITIVE_FILE_RE.test(p) && !SENSITIVE_FILE_ALLOW.test(p)) {
|
|
117
|
+
out.push({ file: p, line: 0, rule: 'tracked-sensitive-file', sev: 'high', cat: 'privacy', why: 'sensitive file is tracked in git (gitignore + remove it)', snippet: path.basename(p) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function gitTrackedFiles(root, { staged = false } = {}) {
|
|
124
|
+
try {
|
|
125
|
+
const args = staged ? ['diff', '--cached', '--name-only', '--diff-filter=ACM'] : ['ls-files'];
|
|
126
|
+
const out = execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
127
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
128
|
+
} catch {
|
|
129
|
+
return null; // not a git repo / git unavailable
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function walk(target, out) {
|
|
134
|
+
let stat;
|
|
135
|
+
try { stat = fs.statSync(target); } catch { return out; }
|
|
136
|
+
if (stat.isFile()) { out.push(target); return out; }
|
|
137
|
+
if (stat.isDirectory()) {
|
|
138
|
+
if (SKIP_DIRS.has(path.basename(target))) return out;
|
|
139
|
+
for (const name of fs.readdirSync(target)) {
|
|
140
|
+
if (name === '.git' || SKIP_DIRS.has(name)) continue;
|
|
141
|
+
walk(path.join(target, name), out);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scannable(file) {
|
|
148
|
+
const ext = path.extname(file);
|
|
149
|
+
return SCAN_EXTS.has(ext) || path.basename(file).startsWith('.env');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve the set of files to scan + the relative-path list for hygiene checks.
|
|
153
|
+
function resolveTargets({ root = process.cwd(), paths = [], staged = false } = {}) {
|
|
154
|
+
if (paths.length) {
|
|
155
|
+
const files = paths.flatMap((p) => walk(path.resolve(root, p), [])).filter(scannable);
|
|
156
|
+
return { files, relPaths: files.map((f) => path.relative(root, f)) };
|
|
157
|
+
}
|
|
158
|
+
const tracked = gitTrackedFiles(root, { staged });
|
|
159
|
+
if (tracked) {
|
|
160
|
+
const files = tracked.filter(scannable).map((p) => path.join(root, p)).filter((f) => fs.existsSync(f));
|
|
161
|
+
return { files, relPaths: tracked };
|
|
162
|
+
}
|
|
163
|
+
const files = walk(root, []).filter(scannable);
|
|
164
|
+
return { files, relPaths: files.map((f) => path.relative(root, f)) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Full scan: line-level findings across files + repo hygiene findings.
|
|
168
|
+
// The scanner's own pattern database and its fixtures necessarily contain the
|
|
169
|
+
// very strings it detects — never scan them (a tool that flags itself is noise).
|
|
170
|
+
const SELF_FILES = new Set(['lib/security-scan.js', 'commands/security-review.js', 'test/security-scan.test.js']);
|
|
171
|
+
|
|
172
|
+
function runScan({ root = process.cwd(), paths = [], staged = false } = {}) {
|
|
173
|
+
const { files, relPaths } = resolveTargets({ root, paths, staged });
|
|
174
|
+
const findings = [];
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
if (SELF_FILES.has(path.relative(root, f))) continue;
|
|
177
|
+
for (const finding of scanFile(f)) findings.push({ ...finding, file: path.relative(root, finding.file) });
|
|
178
|
+
}
|
|
179
|
+
findings.push(...sensitiveFileFindings(relPaths.filter((p) => !SELF_FILES.has(p))));
|
|
180
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
181
|
+
for (const f of findings) counts[f.sev] = (counts[f.sev] || 0) + 1;
|
|
182
|
+
return { findings, counts, scanned: files.length };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
scanLine, scanText, scanFile, sensitiveFileFindings, gitTrackedFiles,
|
|
187
|
+
resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES,
|
|
188
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.0",
|
|
4
4
|
"main": "bin/atris.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"atris": "bin/atris.js",
|
|
@@ -48,13 +48,6 @@
|
|
|
48
48
|
"atris/features/_templates/",
|
|
49
49
|
"atris/features/company-brain-sync/",
|
|
50
50
|
"templates/",
|
|
51
|
-
"atris/wiki/index.md",
|
|
52
|
-
"atris/wiki/concepts/agent-activation-contract.md",
|
|
53
|
-
"atris/wiki/concepts/workspace-initialization-contract.md",
|
|
54
|
-
"atris/wiki/sources/atris-labs-2026-05-10.txt",
|
|
55
|
-
"atris/wiki/sources/atris-labs-goals-2026-05-10.txt",
|
|
56
|
-
"atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt",
|
|
57
|
-
"atris/wiki/sources/jack-dorsey-2026-05-10.txt",
|
|
58
51
|
"atris/policies/",
|
|
59
52
|
"atris/skills/"
|
|
60
53
|
],
|