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.
Files changed (124) 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/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. 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
- // think shows the next card at once (a choice of ideas, one idea, or a
505
- // question when none is ready); pick opens one idea from a choice; sharper
506
- // asks for a new bet; answer saves the owner's answer to the question on
507
- // screen; do it needs a second tap because it starts
508
- // agents; not now only marks the bet skipped.
509
- async function betAction(p, body) {
510
- let out;
511
- if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
512
- // typed words in something else are read first, like say something: a
513
- // question back or confusion is explained and nothing is saved
514
- else if (p === '/bet/answer' && body.choice === 'other') out = await bet.say({ text: String(body.text || ''), card: String(body.card || '') });
515
- else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || ''), card: String(body.card || '') });
516
- else if (p === '/bet/report') out = bet.report({ value: body.value });
517
- else if (p === '/bet/pick') out = bet.pick({ id: String(body.id || '') });
518
- else if (p === '/bet/steer') out = await bet.talk({ member: String(body.member || ''), text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
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 betAction(p, body);
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 = betAction(p, body);
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
- if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
580
- if (req.method === 'GET' && check.path === '/bet/team') return bet ? send(res, 200, bet.teamTable()) : send(res, 404, { ok: false, reason: 'not_found' });
581
- if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
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.startsWith('/bet/')) {
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 };
@@ -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
+ };