atris 3.74.4 → 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.
Files changed (117) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/lib/campaign-keep.js +23 -8
  50. package/lib/campaign-loop.js +24 -5
  51. package/lib/claude-boot-block.js +7 -4
  52. package/lib/engine-registry.js +139 -1
  53. package/lib/fleet.js +11 -2
  54. package/lib/gm-bet.js +338 -34
  55. package/lib/gm-phone-page.js +325 -33
  56. package/lib/gm-phone.js +5 -1
  57. package/lib/gm-season.js +14 -0
  58. package/lib/headless-chrome.js +39 -0
  59. package/lib/idea-gate.js +33 -0
  60. package/lib/idea-inbox.js +220 -0
  61. package/lib/idea-ledger.js +196 -0
  62. package/lib/idea-tick.js +384 -0
  63. package/lib/known-commands.js +1 -1
  64. package/lib/member-engine.js +1 -0
  65. package/lib/ninety-nine-sync.js +156 -0
  66. package/lib/ninety-nine.js +247 -0
  67. package/lib/opening-lines.js +94 -0
  68. package/lib/roster-markdown.js +39 -1
  69. package/lib/roster-models.js +1 -1
  70. package/lib/roster-swap.js +353 -0
  71. package/lib/spawn-env.js +29 -2
  72. package/lib/wish-audit.js +14 -0
  73. package/lib/yes-lock.js +343 -253
  74. package/lib/zen-apply.js +5 -3
  75. package/lib/zen.js +13 -1
  76. package/package.json +1 -1
  77. package/templates/business-starter/CLAUDE.md +7 -7
  78. package/templates/business-starter/MAP.md +1 -1
  79. package/templates/business-starter/TODO.md +1 -1
  80. package/templates/business-starter/atris.md +2 -2
  81. package/templates/business-starter/context/README.md +6 -6
  82. package/templates/business-starter/context/live-workspace.md +1 -1
  83. package/templates/business-starter/goals.md +1 -1
  84. package/templates/business-starter/instructions.md +1 -1
  85. package/templates/business-starter/memory.md +4 -4
  86. package/templates/business-starter/persona.md +3 -3
  87. package/templates/business-starter/policies/LESSONS.md +1 -1
  88. package/templates/business-starter/reports/README.md +1 -1
  89. package/templates/business-starter/skills/README.md +1 -1
  90. package/templates/business-starter/team/README.md +1 -1
  91. package/templates/business-starter/team/START_HERE.md +1 -1
  92. package/templates/business-starter/team/_template/SOUL.md +3 -3
  93. package/templates/business-starter/team/comms/SOUL.md +2 -2
  94. package/templates/business-starter/team/ops/SOUL.md +4 -4
  95. package/templates/business-starter/team/research/SOUL.md +3 -3
  96. package/templates/business-starter/wiki/index.md +1 -1
  97. package/templates/research-canonical/CLAUDE.md +7 -7
  98. package/templates/research-canonical/MAP.md +1 -1
  99. package/templates/research-canonical/TODO.md +1 -1
  100. package/templates/research-canonical/atris.md +2 -2
  101. package/templates/research-canonical/context/README.md +8 -8
  102. package/templates/research-canonical/context/live-workspace.md +1 -1
  103. package/templates/research-canonical/goals.md +2 -2
  104. package/templates/research-canonical/instructions.md +1 -1
  105. package/templates/research-canonical/memory.md +4 -4
  106. package/templates/research-canonical/persona.md +1 -1
  107. package/templates/research-canonical/policies/LESSONS.md +1 -1
  108. package/templates/research-canonical/reports/README.md +1 -1
  109. package/templates/research-canonical/skills/README.md +1 -1
  110. package/templates/research-canonical/team/README.md +1 -1
  111. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  112. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  113. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  114. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  115. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  116. package/templates/research-canonical/wiki/index.md +1 -1
  117. 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
+ };