atris 3.74.4 → 3.74.6
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/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +10 -3
- package/lib/campaign-keep.js +23 -8
- package/lib/campaign-loop.js +24 -5
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- 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 +734 -42
- package/lib/gm-card.js +431 -0
- package/lib/gm-phone-page.js +486 -717
- package/lib/gm-phone.js +33 -34
- 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/task-db.js +8 -4
- package/lib/wish-audit.js +14 -0
- package/lib/yes-lock.js +365 -256
- package/lib/zen-apply.js +6 -3
- package/lib/zen.js +28 -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
package/lib/gm-phone.js
CHANGED
|
@@ -317,6 +317,18 @@ function checkRequest(req, { token, hosts = [] }) {
|
|
|
317
317
|
return { ok: false, status: 400, reason: 'url' };
|
|
318
318
|
}
|
|
319
319
|
if (!hosts.includes(host)) return { ok: false, status: 403, reason: 'host' };
|
|
320
|
+
// Another site is refused on every route, reads too: a page in the owner's
|
|
321
|
+
// browser that holds the key could otherwise read his cards. A fetch says
|
|
322
|
+
// where it came from in Origin or Sec-Fetch-Site; a scan or a typed link
|
|
323
|
+
// says none, and a plain client says nothing. The one exception is a
|
|
324
|
+
// top-level open of the page itself (a link tapped in a mail or chat app):
|
|
325
|
+
// a navigation cannot read anything, and the page load starts no work.
|
|
326
|
+
if (req.headers.origin != null && String(req.headers.origin) !== `http://${host}`) return { ok: false, status: 403, reason: 'origin' };
|
|
327
|
+
const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
|
|
328
|
+
const pageOpen = req.method === 'GET' && url.pathname === '/'
|
|
329
|
+
&& String(req.headers['sec-fetch-mode'] || '').toLowerCase() === 'navigate'
|
|
330
|
+
&& String(req.headers['sec-fetch-dest'] || '').toLowerCase() === 'document';
|
|
331
|
+
if (site && site !== 'same-origin' && site !== 'none' && !pageOpen) return { ok: false, status: 403, reason: 'site' };
|
|
320
332
|
const header = req.headers['x-gm-token'];
|
|
321
333
|
if (req.method === 'GET') {
|
|
322
334
|
if (!tokenMatches(url.searchParams.get('t') || header, token)) return { ok: false, status: 403, reason: 'token' };
|
|
@@ -501,34 +513,21 @@ function createPhoneHandler(deps) {
|
|
|
501
513
|
return { status: ok ? 200 : 500, body: { ok, ack: ok ? 'undone, target back how it was' : 'could not undo' } };
|
|
502
514
|
}
|
|
503
515
|
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true, card: String(body.card || '') });
|
|
520
|
-
else if (p === '/bet/skip') out = bet.skip();
|
|
521
|
-
else if (p === '/bet/do') {
|
|
522
|
-
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
523
|
-
out = await bet.doIt();
|
|
524
|
-
} else if (p === '/bet/nice') {
|
|
525
|
-
out = bet.nice();
|
|
526
|
-
} else if (p === '/bet/yes') {
|
|
527
|
-
// a yes unblocks agents, so it needs a second tap
|
|
528
|
-
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
529
|
-
out = await bet.answerYes();
|
|
530
|
-
} else return { status: 404, body: { ok: false, reason: 'not_found' } };
|
|
531
|
-
return { status: out.ok ? 200 : 409, body: out };
|
|
516
|
+
// The one route for the card on screen (lib/gm-card.js): the card's id and
|
|
517
|
+
// one option on it, or typed words for its box, or the orb's next card, or
|
|
518
|
+
// the team or the job deck from the footer. The desk checks the card id
|
|
519
|
+
// and the option, and refuses anything else. A second tap is the page
|
|
520
|
+
// sending confirm on an option that asks for one.
|
|
521
|
+
function cardAction(body) {
|
|
522
|
+
const clean = {
|
|
523
|
+
card_id: typeof body.card_id === 'string' ? body.card_id.slice(0, 300) : null,
|
|
524
|
+
option_id: typeof body.option_id === 'string' ? body.option_id.slice(0, 120) : null,
|
|
525
|
+
confirm: body.confirm === true,
|
|
526
|
+
orb: body.orb === true,
|
|
527
|
+
open: body.open === 'team' || body.open === 'deck' ? body.open : null,
|
|
528
|
+
};
|
|
529
|
+
if (typeof body.text === 'string') clean.text = body.text.slice(0, 1000);
|
|
530
|
+
return Promise.resolve(bet.act(clean));
|
|
532
531
|
}
|
|
533
532
|
|
|
534
533
|
// A typed send carries its own id, made on the phone when the words are
|
|
@@ -539,11 +538,11 @@ function createPhoneHandler(deps) {
|
|
|
539
538
|
const sends = new Map();
|
|
540
539
|
function sendOnce(p, body) {
|
|
541
540
|
const id = typeof body.send === 'string' && SEND_ID.test(body.send) ? `${p} ${body.send}` : null;
|
|
542
|
-
if (!id) return
|
|
541
|
+
if (!id) return cardAction(body);
|
|
543
542
|
const seen = sends.get(id);
|
|
544
543
|
if (seen && now() - seen.at < SENDS_MS) return seen.out;
|
|
545
544
|
sends.delete(id);
|
|
546
|
-
const out =
|
|
545
|
+
const out = cardAction(body);
|
|
547
546
|
sends.set(id, { at: now(), out });
|
|
548
547
|
const forget = () => { if (sends.get(id) && sends.get(id).out === out) sends.delete(id); };
|
|
549
548
|
out.then(r => { if (!r.body || r.body.ok !== true) forget(); }, forget);
|
|
@@ -576,9 +575,9 @@ function createPhoneHandler(deps) {
|
|
|
576
575
|
});
|
|
577
576
|
}
|
|
578
577
|
if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
|
|
579
|
-
|
|
580
|
-
if (req.method === 'GET' && check.path === '/
|
|
581
|
-
if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path
|
|
578
|
+
// the card on screen now; a read starts no work
|
|
579
|
+
if (req.method === 'GET' && check.path === '/card') return bet ? send(res, 200, bet.card()) : send(res, 404, { ok: false, reason: 'not_found' });
|
|
580
|
+
if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path === '/act'))) {
|
|
582
581
|
if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') return send(res, 415, { ok: false, reason: 'json_only' });
|
|
583
582
|
let body;
|
|
584
583
|
try {
|
|
@@ -587,7 +586,7 @@ function createPhoneHandler(deps) {
|
|
|
587
586
|
} catch (err) {
|
|
588
587
|
return send(res, err.message === 'too_large' ? 413 : 400, { ok: false, reason: 'bad_body' });
|
|
589
588
|
}
|
|
590
|
-
if (check.path
|
|
589
|
+
if (check.path === '/act') {
|
|
591
590
|
const out = await sendOnce(check.path, body || {});
|
|
592
591
|
return send(res, out.status, out.body);
|
|
593
592
|
}
|
package/lib/gm-season.js
CHANGED
|
@@ -58,6 +58,19 @@ function ovr(outcomes) {
|
|
|
58
58
|
return Math.round(60 + 39 * landed / finished);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Each member's rating over one window of plays, the board's own numbers:
|
|
62
|
+
// ovr is 60 to 99, or null for a rookie under 3 finished. A member with no
|
|
63
|
+
// plays in the window is not in the map.
|
|
64
|
+
function memberRatings(outcomes = []) {
|
|
65
|
+
const groups = new Map();
|
|
66
|
+
for (const o of outcomes || []) {
|
|
67
|
+
if (!o || !o.member) continue;
|
|
68
|
+
if (!groups.has(o.member)) groups.set(o.member, []);
|
|
69
|
+
groups.get(o.member).push(o);
|
|
70
|
+
}
|
|
71
|
+
return new Map([...groups].map(([name, list]) => [name, { ovr: ovr(list), finished: tally(list).finished }]));
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
// Last n finished jobs, oldest on the left, newest on the right.
|
|
62
75
|
function form(outcomes, n = 5) {
|
|
63
76
|
return [...(outcomes || [])]
|
|
@@ -1146,6 +1159,7 @@ module.exports = {
|
|
|
1146
1159
|
form,
|
|
1147
1160
|
lastPlays,
|
|
1148
1161
|
loadSeason,
|
|
1162
|
+
memberRatings,
|
|
1149
1163
|
outcomesFromEvents,
|
|
1150
1164
|
ovr,
|
|
1151
1165
|
ownerMood,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One place for how this CLI launches headless Chrome. Every spot that starts
|
|
4
|
+
// Chrome builds its args here so a new launch can't forget the rules:
|
|
5
|
+
//
|
|
6
|
+
// --use-mock-keychain Chrome never touches the macOS keychain, so nobody
|
|
7
|
+
// --password-store=basic gets a "Chrome Safe Storage" prompt
|
|
8
|
+
// --user-data-dir a throwaway profile under the system temp folder,
|
|
9
|
+
// never the person's real Chrome profile
|
|
10
|
+
//
|
|
11
|
+
// Make the profile with makeProfileDir, build args with headlessArgs, and
|
|
12
|
+
// remove the profile with removeProfileDir when the launch is done.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const KEYCHAIN_FLAGS = Object.freeze(['--use-mock-keychain', '--password-store=basic']);
|
|
19
|
+
|
|
20
|
+
function makeProfileDir(prefix = 'atris-chrome-') {
|
|
21
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function removeProfileDir(dir) {
|
|
25
|
+
try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Args for one headless launch. `profileDir` must sit under the system temp
|
|
29
|
+
// folder; anything else throws, so a real profile can't be passed by mistake.
|
|
30
|
+
function headlessArgs(profileDir, extra = []) {
|
|
31
|
+
const tmpRoot = path.resolve(os.tmpdir());
|
|
32
|
+
const dir = profileDir ? path.resolve(String(profileDir)) : '';
|
|
33
|
+
if (!dir || !dir.startsWith(tmpRoot + path.sep)) {
|
|
34
|
+
throw new Error('headless chrome needs a throwaway profile under the temp folder (makeProfileDir)');
|
|
35
|
+
}
|
|
36
|
+
return ['--headless=new', ...KEYCHAIN_FLAGS, `--user-data-dir=${dir}`, ...extra];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { KEYCHAIN_FLAGS, makeProfileDir, removeProfileDir, headlessArgs };
|
package/lib/idea-gate.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The two gates every idea passes before it lands, kept apart from the tick
|
|
4
|
+
// so the loop cannot loosen them: a built change that edits this file is
|
|
5
|
+
// refused before landing (see JUDGE_PATHS in lib/idea-inbox.js).
|
|
6
|
+
|
|
7
|
+
const FORBIDDEN_SHIP_FLAGS = Object.freeze(['--force', '--force-with-lease', '--no-verify', '-f']);
|
|
8
|
+
|
|
9
|
+
// The builder and the reviewer must be two different engines, and the
|
|
10
|
+
// reviewer must have passed the change. One-lap enforces this already; the
|
|
11
|
+
// tick checks it again so a change can never land on its builder's word.
|
|
12
|
+
function secondReviewerSignedOff(lap) {
|
|
13
|
+
const validator = lap && lap.result && lap.result.validator_result;
|
|
14
|
+
return Boolean(validator
|
|
15
|
+
&& validator.passed === true
|
|
16
|
+
&& validator.independent === true
|
|
17
|
+
&& validator.engine
|
|
18
|
+
&& validator.executor_engine
|
|
19
|
+
&& validator.engine !== validator.executor_engine);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Landing never force-pushes and never skips hooks.
|
|
23
|
+
function assertSafeShipArgs(args) {
|
|
24
|
+
for (const flag of FORBIDDEN_SHIP_FLAGS) {
|
|
25
|
+
if (args.includes(flag)) throw new Error(`refusing to land with ${flag}`);
|
|
26
|
+
}
|
|
27
|
+
return args;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
assertSafeShipArgs,
|
|
32
|
+
secondReviewerSignedOff,
|
|
33
|
+
};
|
|
@@ -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
|
+
};
|