atris 3.74.3 → 3.74.5
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 +12 -12
- package/atris/AGENTS.md +7 -7
- package/atris/CLAUDE.md +10 -10
- package/atris/policies/atris-backend.md +1 -1
- package/atris/policies/atris-design.md +71 -71
- package/atris/policies/design-seed.md +28 -28
- package/atris/policies/writing.md +1 -1
- package/atris/skills/apps/SKILL.md +3 -3
- package/atris/skills/atris-feedback/SKILL.md +2 -2
- package/atris/skills/autopilot/SKILL.md +10 -10
- package/atris/skills/blocks/SKILL.md +16 -16
- package/atris/skills/calendar/SKILL.md +1 -1
- package/atris/skills/clawhub/atris/SKILL.md +4 -4
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
- package/atris/skills/create-app/SKILL.md +18 -18
- package/atris/skills/create-member/SKILL.md +11 -11
- package/atris/skills/design/SKILL.md +1 -1
- package/atris/skills/drive/SKILL.md +25 -25
- package/atris/skills/email-agent/SKILL.md +11 -11
- package/atris/skills/endgame/SKILL.md +33 -33
- package/atris/skills/magic-inbox/SKILL.md +15 -15
- package/atris/skills/notion/SKILL.md +1 -1
- package/atris/skills/ramp/SKILL.md +7 -7
- package/atris/skills/research-search/SKILL.md +7 -7
- package/atris/skills/slides/SKILL.md +7 -7
- package/atris/skills/tidy/SKILL.md +11 -11
- package/atris/skills/wake/SKILL.md +11 -11
- package/atris/skills/writing/SKILL.md +5 -5
- package/atris/team/_template/SOUL.md +3 -3
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris/team/launcher/MEMBER.md +2 -2
- package/atris/team/mission-lead/MISSION.md +7 -7
- package/atris/team/opus-overnight/MISSION.md +16 -16
- package/atris/team/opus-overnight/STEERING.md +5 -5
- package/atris.md +9 -0
- package/bin/atris.js +16 -3
- package/commands/campaign.js +17 -2
- package/commands/card.js +19 -12
- package/commands/engine.js +92 -17
- package/commands/gm.js +30 -0
- package/commands/idea.js +263 -0
- package/commands/init.js +11 -2
- package/commands/land.js +39 -18
- package/commands/mission.js +4 -3
- package/commands/ninety-nine.js +288 -0
- package/commands/one-lap.js +46 -27
- package/commands/reel.js +12 -7
- package/commands/sync.js +10 -2
- package/lib/campaign-keep.js +23 -8
- package/lib/campaign-loop.js +24 -5
- package/lib/claude-boot-block.js +7 -4
- package/lib/engine-registry.js +139 -1
- package/lib/fleet.js +11 -2
- package/lib/gm-bet.js +338 -34
- package/lib/gm-phone-page.js +325 -33
- package/lib/gm-phone.js +5 -1
- package/lib/gm-season.js +14 -0
- package/lib/headless-chrome.js +39 -0
- package/lib/idea-gate.js +33 -0
- package/lib/idea-inbox.js +220 -0
- package/lib/idea-ledger.js +196 -0
- package/lib/idea-tick.js +384 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +1 -0
- package/lib/ninety-nine-sync.js +156 -0
- package/lib/ninety-nine.js +247 -0
- package/lib/opening-lines.js +94 -0
- package/lib/roster-markdown.js +39 -1
- package/lib/roster-models.js +1 -1
- package/lib/roster-swap.js +353 -0
- package/lib/spawn-env.js +29 -2
- package/lib/wish-audit.js +14 -0
- package/lib/yes-lock.js +358 -241
- package/lib/zen-apply.js +5 -3
- package/lib/zen.js +13 -1
- package/package.json +1 -1
- package/templates/business-starter/CLAUDE.md +7 -7
- package/templates/business-starter/MAP.md +1 -1
- package/templates/business-starter/TODO.md +1 -1
- package/templates/business-starter/atris.md +2 -2
- package/templates/business-starter/context/README.md +6 -6
- package/templates/business-starter/context/live-workspace.md +1 -1
- package/templates/business-starter/goals.md +1 -1
- package/templates/business-starter/instructions.md +1 -1
- package/templates/business-starter/memory.md +4 -4
- package/templates/business-starter/persona.md +3 -3
- package/templates/business-starter/policies/LESSONS.md +1 -1
- package/templates/business-starter/reports/README.md +1 -1
- package/templates/business-starter/skills/README.md +1 -1
- package/templates/business-starter/team/README.md +1 -1
- package/templates/business-starter/team/START_HERE.md +1 -1
- package/templates/business-starter/team/_template/SOUL.md +3 -3
- package/templates/business-starter/team/comms/SOUL.md +2 -2
- package/templates/business-starter/team/ops/SOUL.md +4 -4
- package/templates/business-starter/team/research/SOUL.md +3 -3
- package/templates/business-starter/wiki/index.md +1 -1
- package/templates/research-canonical/CLAUDE.md +7 -7
- package/templates/research-canonical/MAP.md +1 -1
- package/templates/research-canonical/TODO.md +1 -1
- package/templates/research-canonical/atris.md +2 -2
- package/templates/research-canonical/context/README.md +8 -8
- package/templates/research-canonical/context/live-workspace.md +1 -1
- package/templates/research-canonical/goals.md +2 -2
- package/templates/research-canonical/instructions.md +1 -1
- package/templates/research-canonical/memory.md +4 -4
- package/templates/research-canonical/persona.md +1 -1
- package/templates/research-canonical/policies/LESSONS.md +1 -1
- package/templates/research-canonical/reports/README.md +1 -1
- package/templates/research-canonical/skills/README.md +1 -1
- package/templates/research-canonical/team/README.md +1 -1
- package/templates/research-canonical/team/eval/SOUL.md +1 -1
- package/templates/research-canonical/team/experiment/SOUL.md +2 -2
- package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
- package/templates/research-canonical/team/literature/SOUL.md +2 -2
- package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
- package/templates/research-canonical/wiki/index.md +1 -1
- package/utils/auth.js +1 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The idea inbox: the owner drops a plain sentence, walks away, and a tick
|
|
4
|
+
// later turns the oldest one into a normal Atris task (through the one-lap
|
|
5
|
+
// path) and builds it. This file holds only the raw sentences and what
|
|
6
|
+
// happened to each one. The work itself lives in the task DB like any other
|
|
7
|
+
// task; an idea that was picked up records its task id.
|
|
8
|
+
//
|
|
9
|
+
// Storage is an append-only JSONL log in the repo's own `.atris/state/`, so a
|
|
10
|
+
// tick and a new `atris idea "..."` can write at the same time without one
|
|
11
|
+
// clobbering the other.
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const STATUSES = Object.freeze(['waiting', 'building', 'landed', 'refused']);
|
|
18
|
+
|
|
19
|
+
// Version 1 safety rails are plain keyword and path checks, on purpose. They
|
|
20
|
+
// refuse too much rather than too little: a refused idea stays on the list as
|
|
21
|
+
// "needs owner" with the reason, and nothing is lost. A smarter check can
|
|
22
|
+
// replace these later; until then the rule is easy to read and easy to test.
|
|
23
|
+
const REFUSE_RULES = Object.freeze([
|
|
24
|
+
{
|
|
25
|
+
area: 'money',
|
|
26
|
+
text: /\b(pay|payments?|payouts?|paid|billing|invoices?|charges?|charging|refunds?|stripe|pricing|prices?|credit\s+cards?|wire\s+transfers?|bank(?:ing)?|payroll|subscriptions?|checkout|trades?|trading)\b/i,
|
|
27
|
+
path: /(?:^|[\/_.-])(?:billing|payments?|payouts?|stripe|invoices?|refunds?|checkout|pricing|wallet|payroll|mercury|brex|robinhood)(?:[\/_.-]|$)/i,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
area: 'sending messages or email',
|
|
31
|
+
text: /\b(send|sends|sending|sent|resend|e-?mails?\s+(?:to|out|users|customers|people|everyone|them|him|her)|e-?mailing|mass\s+e-?mail|cold\s+e-?mail|sms|imessage|text\s+messages?|notify|dm|dms|tweet|tweets|post\s+(?:to|on)|outreach|newsletter)\b/i,
|
|
32
|
+
path: /(?:^|[\/_.-])(?:send|sender|outreach|mailer|smtp|ses|sms|imessage|outbox|notify)(?:[\/_.-]|$)/i,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
area: 'auth',
|
|
36
|
+
text: /\b(auth|oauth|authn|authz|authentication|authori[sz]ation|log\s?in|logins?|sign\s?in|sign\s?up|passwords?|credentials?|secrets?|api\s+keys?|tokens?|sessions?|permissions?|2fa|mfa|sso|jwt|cookies?)\b/i,
|
|
37
|
+
path: /(?:^|[\/_.-])(?:auth|oauth|login|signin|password|passwords|credentials?|secrets?|tokens?|sessions?|sso|jwt|permissions?|keychain|env)(?:[\/_.-]|$)/i,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
area: 'deleting user data',
|
|
41
|
+
text: /\b(?:delete|deleting|drop|dropping|purge|purging|wipe|wiping|truncate|erase|destroy)\b.{0,40}\b(?:users?|customers?|accounts?|data|database|db|tables?|records?|rows?|history|files?)\b|\b(?:drop\s+table|truncate\s+table|delete\s+from|rm\s+-rf)\b/i,
|
|
42
|
+
path: null,
|
|
43
|
+
added: /\b(?:DROP\s+TABLE|TRUNCATE\s+TABLE|DELETE\s+FROM)\b|\brm\s+-rf\s+[~\/$]/i,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
area: 'database migrations',
|
|
47
|
+
text: /\b(migrations?|migrate|migrating|schema\s+changes?|alter\s+table|alembic|add\s+(?:a\s+)?column|drop\s+column)\b/i,
|
|
48
|
+
path: /(?:^|\/)(?:migrations?|alembic)\/|\.sql$/i,
|
|
49
|
+
added: /\bALTER\s+TABLE\b/i,
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// The files that judge the idea loop: the refuse rules above, the landing
|
|
54
|
+
// gates in lib/idea-gate.js, and the score in lib/idea-ledger.js, with their
|
|
55
|
+
// tests. A built change that edits any of them is refused before landing,
|
|
56
|
+
// whatever the idea said, so the loop never edits the rules that judge it.
|
|
57
|
+
// This list lives in a file it protects.
|
|
58
|
+
const JUDGE_PATHS = Object.freeze([
|
|
59
|
+
/^lib\/idea-inbox\.js$/,
|
|
60
|
+
/^lib\/idea-gate\.js$/,
|
|
61
|
+
/^lib\/idea-ledger\.js$/,
|
|
62
|
+
/^test\/idea-inbox\.test\.js$/,
|
|
63
|
+
/^test\/idea-ledger\.test\.js$/,
|
|
64
|
+
]);
|
|
65
|
+
const JUDGE_TEXT = /\brefus\w*\s+(?:list|rules?)\b|\bidea\s+(?:score|scoring|ledger)\b|\bscoring\s+code\b/i;
|
|
66
|
+
|
|
67
|
+
function stateFile(root) {
|
|
68
|
+
return path.join(path.resolve(root || '.'), '.atris', 'state', 'ideas.jsonl');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cleanText(value) {
|
|
72
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function oneLine(value, limit = 160) {
|
|
76
|
+
const text = cleanText(value);
|
|
77
|
+
return text.length > limit ? `${text.slice(0, limit - 3).trimEnd()}...` : text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Why an idea in plain words is not safe to build alone, or '' when it is.
|
|
81
|
+
function ideaRefusal(text) {
|
|
82
|
+
const words = cleanText(text);
|
|
83
|
+
for (const rule of REFUSE_RULES) {
|
|
84
|
+
const hit = words.match(rule.text);
|
|
85
|
+
if (hit) return `touches ${rule.area} ("${cleanText(hit[0]).toLowerCase()}"), needs owner`;
|
|
86
|
+
}
|
|
87
|
+
const judge = words.match(JUDGE_TEXT);
|
|
88
|
+
if (judge) return `touches the rules that judge the idea loop ("${cleanText(judge[0]).toLowerCase()}"), needs owner`;
|
|
89
|
+
return '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Why a built change is not safe to land alone, or '' when it is. `paths` are
|
|
93
|
+
// the changed file paths; `addedLines` are the added lines of the diff.
|
|
94
|
+
function changeRefusal({ paths = [], addedLines = [] } = {}) {
|
|
95
|
+
const judged = paths.find((entry) => JUDGE_PATHS.some((rule) => rule.test(String(entry || ''))));
|
|
96
|
+
if (judged) return `the change edits the rules that judge the idea loop (${judged}), needs owner`;
|
|
97
|
+
for (const rule of REFUSE_RULES) {
|
|
98
|
+
if (rule.path) {
|
|
99
|
+
const file = paths.find((entry) => rule.path.test(String(entry || '')));
|
|
100
|
+
if (file) return `the change touches ${rule.area} (${file}), needs owner`;
|
|
101
|
+
}
|
|
102
|
+
if (rule.added) {
|
|
103
|
+
const line = addedLines.find((entry) => rule.added.test(String(entry || '')));
|
|
104
|
+
if (line) return `the change touches ${rule.area} (${oneLine(line, 60)}), needs owner`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readEvents(root) {
|
|
111
|
+
let text = '';
|
|
112
|
+
try {
|
|
113
|
+
text = fs.readFileSync(stateFile(root), 'utf8');
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const events = [];
|
|
118
|
+
for (const line of text.split(/\r?\n/)) {
|
|
119
|
+
if (!line.trim()) continue;
|
|
120
|
+
try {
|
|
121
|
+
const row = JSON.parse(line);
|
|
122
|
+
if (row && typeof row === 'object' && row.id) events.push(row);
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
return events;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function appendEvent(root, event) {
|
|
129
|
+
const file = stateFile(root);
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
fs.appendFileSync(file, `${JSON.stringify(event)}\n`, 'utf8');
|
|
132
|
+
return event;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Ideas in the order they arrived, each with its latest status folded in.
|
|
136
|
+
function readIdeas(root) {
|
|
137
|
+
const byId = new Map();
|
|
138
|
+
const order = [];
|
|
139
|
+
for (const event of readEvents(root)) {
|
|
140
|
+
if (event.kind === 'idea') {
|
|
141
|
+
if (byId.has(event.id)) continue;
|
|
142
|
+
const idea = {
|
|
143
|
+
id: event.id,
|
|
144
|
+
n: order.length + 1,
|
|
145
|
+
text: cleanText(event.text),
|
|
146
|
+
at: event.at || null,
|
|
147
|
+
status: 'waiting',
|
|
148
|
+
reason: '',
|
|
149
|
+
attempts: 0,
|
|
150
|
+
...(event.verify ? { verify: event.verify } : {}),
|
|
151
|
+
...(event.tag ? { tag: event.tag } : {}),
|
|
152
|
+
...(event.cause_key ? { cause_key: event.cause_key } : {}),
|
|
153
|
+
};
|
|
154
|
+
byId.set(event.id, idea);
|
|
155
|
+
order.push(idea);
|
|
156
|
+
} else if (event.kind === 'update' && byId.has(event.id)) {
|
|
157
|
+
const idea = byId.get(event.id);
|
|
158
|
+
const { kind, id, ...patch } = event;
|
|
159
|
+
Object.assign(idea, patch);
|
|
160
|
+
if (patch.status && patch.status !== 'refused' && !('reason' in patch)) idea.reason = '';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return order;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function newIdeaId(now = new Date()) {
|
|
167
|
+
return `idea-${now.getTime().toString(36)}${crypto.randomBytes(2).toString('hex')}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Saves one idea. A sentence that trips a rail is saved as refused right away,
|
|
171
|
+
// so the owner sees it on the list with its reason instead of losing it. The
|
|
172
|
+
// tick files its own ideas through here too (tag 'loop'), under the same rails.
|
|
173
|
+
function addIdea(root, text, { verify = '', tag = '', causeKey = '', now = new Date() } = {}) {
|
|
174
|
+
const sentence = cleanText(text);
|
|
175
|
+
if (!sentence) throw new Error('an idea needs a sentence');
|
|
176
|
+
const id = newIdeaId(now);
|
|
177
|
+
const at = now.toISOString();
|
|
178
|
+
appendEvent(root, {
|
|
179
|
+
kind: 'idea',
|
|
180
|
+
id,
|
|
181
|
+
text: sentence,
|
|
182
|
+
at,
|
|
183
|
+
...(verify ? { verify: cleanText(verify) } : {}),
|
|
184
|
+
...(tag ? { tag: cleanText(tag) } : {}),
|
|
185
|
+
...(causeKey ? { cause_key: causeKey } : {}),
|
|
186
|
+
});
|
|
187
|
+
const refusal = ideaRefusal(sentence);
|
|
188
|
+
if (refusal) appendEvent(root, { kind: 'update', id, at, status: 'refused', reason: refusal });
|
|
189
|
+
return readIdeas(root).find((idea) => idea.id === id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function updateIdea(root, id, patch = {}, { now = new Date() } = {}) {
|
|
193
|
+
if (patch.status && !STATUSES.includes(patch.status)) throw new Error(`unknown idea status: ${patch.status}`);
|
|
194
|
+
appendEvent(root, { kind: 'update', id, at: now.toISOString(), ...patch });
|
|
195
|
+
return readIdeas(root).find((idea) => idea.id === id) || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function findIdea(ideas, ref) {
|
|
199
|
+
const key = String(ref || '').trim().replace(/^#/, '');
|
|
200
|
+
if (!key) return null;
|
|
201
|
+
if (/^\d+$/.test(key)) return ideas.find((idea) => idea.n === Number(key)) || null;
|
|
202
|
+
return ideas.find((idea) => idea.id === key) || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The oldest idea still waiting to be built.
|
|
206
|
+
function nextWaitingIdea(ideas) {
|
|
207
|
+
return (ideas || []).find((idea) => idea.status === 'waiting') || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
addIdea,
|
|
212
|
+
changeRefusal,
|
|
213
|
+
findIdea,
|
|
214
|
+
ideaRefusal,
|
|
215
|
+
nextWaitingIdea,
|
|
216
|
+
oneLine,
|
|
217
|
+
readIdeas,
|
|
218
|
+
stateFile,
|
|
219
|
+
updateIdea,
|
|
220
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The idea outcome ledger and the score read from it.
|
|
4
|
+
//
|
|
5
|
+
// Every idea tick that picks up an idea appends one JSON line here saying how
|
|
6
|
+
// it ended: landed, refused (a safety rail said no), failed (the build, the
|
|
7
|
+
// review, the checks, or the push did not get through), or reverted. The
|
|
8
|
+
// score `atris idea` prints comes only from these lines, and the tick reads
|
|
9
|
+
// the same lines to notice a failure that keeps coming back.
|
|
10
|
+
//
|
|
11
|
+
// This file judges the loop. A built change that edits it is refused before
|
|
12
|
+
// landing (see JUDGE_PATHS in lib/idea-inbox.js), so the loop can never raise
|
|
13
|
+
// its own score by rewriting how the score is counted.
|
|
14
|
+
//
|
|
15
|
+
// Storage follows the usual `.atris/state/<name>.jsonl` convention: append
|
|
16
|
+
// only, one `atris.idea_outcome.v1` row per line, gitignored.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const OUTCOMES = Object.freeze(['landed', 'refused', 'failed', 'reverted']);
|
|
22
|
+
const STAGES = Object.freeze(['intake', 'build', 'review', 'checks', 'push']);
|
|
23
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
const SCORE_WINDOW_DAYS = 7;
|
|
25
|
+
const REPEAT_COUNT = 2;
|
|
26
|
+
|
|
27
|
+
function outcomesFile(root) {
|
|
28
|
+
return path.join(path.resolve(root || '.'), '.atris', 'state', 'idea_outcomes.jsonl');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function short(value, limit = 60) {
|
|
32
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
33
|
+
return text.length > limit ? `${text.slice(0, limit - 3).trimEnd()}...` : text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function timeMs(value) {
|
|
37
|
+
const ms = Date.parse(value || '');
|
|
38
|
+
return Number.isFinite(ms) ? ms : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function minutesBetween(from, to) {
|
|
42
|
+
const a = timeMs(from);
|
|
43
|
+
const b = timeMs(to);
|
|
44
|
+
if (a === null || b === null || b < a) return null;
|
|
45
|
+
return Math.round(((b - a) / 60000) * 10) / 10;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Appends one outcome row. `idea` is the inbox row the tick worked on.
|
|
49
|
+
function recordOutcome(root, {
|
|
50
|
+
idea,
|
|
51
|
+
outcome,
|
|
52
|
+
stage = null,
|
|
53
|
+
cause = '',
|
|
54
|
+
started = null,
|
|
55
|
+
finished = new Date().toISOString(),
|
|
56
|
+
taskId = null,
|
|
57
|
+
link = null,
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (!idea || !idea.id) throw new Error('an outcome needs the idea it belongs to');
|
|
60
|
+
if (!OUTCOMES.includes(outcome)) throw new Error(`unknown idea outcome: ${outcome}`);
|
|
61
|
+
if (stage !== null && !STAGES.includes(stage)) throw new Error(`unknown idea stage: ${stage}`);
|
|
62
|
+
const repo = path.resolve(root || '.');
|
|
63
|
+
const row = {
|
|
64
|
+
schema: 'atris.idea_outcome.v1',
|
|
65
|
+
id: idea.id,
|
|
66
|
+
n: idea.n || null,
|
|
67
|
+
repo,
|
|
68
|
+
idea_at: idea.at || null,
|
|
69
|
+
started: started || finished,
|
|
70
|
+
finished,
|
|
71
|
+
minutes: minutesBetween(started || finished, finished),
|
|
72
|
+
outcome,
|
|
73
|
+
cause: short(cause) || null,
|
|
74
|
+
stage: outcome === 'landed' ? null : stage,
|
|
75
|
+
attempt: Number(idea.attempts || 0) || null,
|
|
76
|
+
...(taskId ? { task_id: taskId } : {}),
|
|
77
|
+
...(link ? { link } : {}),
|
|
78
|
+
...(idea.tag ? { tag: idea.tag } : {}),
|
|
79
|
+
};
|
|
80
|
+
const file = outcomesFile(repo);
|
|
81
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
|
|
83
|
+
return row;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readOutcomes(root) {
|
|
87
|
+
let text = '';
|
|
88
|
+
try {
|
|
89
|
+
text = fs.readFileSync(outcomesFile(root), 'utf8');
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const rows = [];
|
|
94
|
+
for (const line of text.split(/\r?\n/)) {
|
|
95
|
+
if (!line.trim()) continue;
|
|
96
|
+
try {
|
|
97
|
+
const row = JSON.parse(line);
|
|
98
|
+
if (row && row.schema === 'atris.idea_outcome.v1' && row.id && OUTCOMES.includes(row.outcome)) rows.push(row);
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Two failures are "the same" when they stopped at the same stage and their
|
|
105
|
+
// causes read the same once paths, numbers, quotes, and ids are taken out.
|
|
106
|
+
// Plain on purpose, like the refuse rules: easy to read, easy to test.
|
|
107
|
+
function causeKey(stage, cause) {
|
|
108
|
+
const words = String(cause || '').toLowerCase()
|
|
109
|
+
.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, ' ')
|
|
110
|
+
.replace(/\S*[\/\\]\S*/g, ' ')
|
|
111
|
+
.replace(/\b[0-9a-f]{7,}\b/g, ' ')
|
|
112
|
+
.replace(/[^a-z]+/g, ' ')
|
|
113
|
+
.trim()
|
|
114
|
+
.split(/\s+/)
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.slice(0, 6);
|
|
117
|
+
return `${stage || 'none'}|${words.join(' ')}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function median(values) {
|
|
121
|
+
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
|
122
|
+
if (!sorted.length) return null;
|
|
123
|
+
const mid = Math.floor(sorted.length / 2);
|
|
124
|
+
const value = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
125
|
+
return Math.round(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The score: ideas landed in the last 24 hours, the share of ideas that
|
|
129
|
+
// landed on their first build, and the median minutes from dropping an idea
|
|
130
|
+
// to it landing. The last two look back 7 days so a quiet day still says
|
|
131
|
+
// something. Intake refusals are not builds and do not count as tries.
|
|
132
|
+
function ideaScore(rows, { now = Date.now() } = {}) {
|
|
133
|
+
const nowMs = typeof now === 'number' ? now : timeMs(now);
|
|
134
|
+
const inLast = (row, ms) => {
|
|
135
|
+
const at = timeMs(row.finished);
|
|
136
|
+
return at !== null && nowMs - at < ms;
|
|
137
|
+
};
|
|
138
|
+
const builds = (rows || []).filter((row) => row.stage !== 'intake');
|
|
139
|
+
const landed24h = builds.filter((row) => row.outcome === 'landed' && inLast(row, DAY_MS)).length;
|
|
140
|
+
const firstById = new Map();
|
|
141
|
+
for (const row of builds) {
|
|
142
|
+
const prior = firstById.get(row.id);
|
|
143
|
+
if (!prior || (timeMs(row.finished) || 0) < (timeMs(prior.finished) || 0)) firstById.set(row.id, row);
|
|
144
|
+
}
|
|
145
|
+
const windowMs = SCORE_WINDOW_DAYS * DAY_MS;
|
|
146
|
+
const firsts = [...firstById.values()].filter((row) => inLast(row, windowMs));
|
|
147
|
+
const firstLanded = firsts.filter((row) => row.outcome === 'landed').length;
|
|
148
|
+
const toLanded = builds
|
|
149
|
+
.filter((row) => row.outcome === 'landed' && inLast(row, windowMs))
|
|
150
|
+
.map((row) => minutesBetween(row.idea_at, row.finished));
|
|
151
|
+
return {
|
|
152
|
+
landed_24h: landed24h,
|
|
153
|
+
first_try_landed: firstLanded,
|
|
154
|
+
first_try_tried: firsts.length,
|
|
155
|
+
first_try_rate: firsts.length ? Math.round((firstLanded / firsts.length) * 100) : null,
|
|
156
|
+
median_minutes_to_landed: median(toLanded),
|
|
157
|
+
window_days: SCORE_WINDOW_DAYS,
|
|
158
|
+
builds: builds.length,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scoreLine(score) {
|
|
163
|
+
if (!score || !score.builds) return 'score: nothing built yet.';
|
|
164
|
+
const parts = [`score: ${score.landed_24h} landed in the last 24h.`];
|
|
165
|
+
if (score.first_try_tried) {
|
|
166
|
+
parts.push(`${score.first_try_landed} of ${score.first_try_tried} landed first try (${score.first_try_rate}%, last ${score.window_days} days).`);
|
|
167
|
+
}
|
|
168
|
+
parts.push(score.median_minutes_to_landed === null
|
|
169
|
+
? 'no landings yet to time.'
|
|
170
|
+
: `median ${score.median_minutes_to_landed} min from idea to landed.`);
|
|
171
|
+
return parts.join(' ');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The failure that keeps coming back: the latest failed row, when at least
|
|
175
|
+
// REPEAT_COUNT failures in the last 24 hours share its stage and cause.
|
|
176
|
+
function repeatedFailure(rows, { now = Date.now(), count = REPEAT_COUNT } = {}) {
|
|
177
|
+
const nowMs = typeof now === 'number' ? now : timeMs(now);
|
|
178
|
+
const recent = (rows || []).filter((row) => {
|
|
179
|
+
const at = timeMs(row.finished);
|
|
180
|
+
return row.outcome === 'failed' && at !== null && nowMs - at < DAY_MS;
|
|
181
|
+
});
|
|
182
|
+
if (!recent.length) return null;
|
|
183
|
+
const latest = recent[recent.length - 1];
|
|
184
|
+
const key = causeKey(latest.stage, latest.cause);
|
|
185
|
+
const same = recent.filter((row) => causeKey(row.stage, row.cause) === key);
|
|
186
|
+
if (same.length < count) return null;
|
|
187
|
+
return { key, stage: latest.stage, cause: latest.cause, count: same.length };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
ideaScore,
|
|
192
|
+
readOutcomes,
|
|
193
|
+
recordOutcome,
|
|
194
|
+
repeatedFailure,
|
|
195
|
+
scoreLine,
|
|
196
|
+
};
|