atris 3.40.0 → 3.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/atris.js CHANGED
@@ -1709,6 +1709,18 @@ function showWelcomeVisualization() {
1709
1709
  console.log(row('tidy', tidyBits.join(', ')));
1710
1710
  }
1711
1711
 
1712
+ // The guarantee gauge, only when it has bad news: landings a human had to
1713
+ // fix this week. Zero is the healthy state and stays silent; no git history
1714
+ // (not a repo, git missing) also stays silent.
1715
+ try {
1716
+ const rev = require('../commands/improve').collectRevisionSignals(cwd, { days: 7 });
1717
+ if (rev.revised > 0) {
1718
+ console.log(` ${rev.revised} landing${rev.revised === 1 ? '' : 's'} this week needed a human fix; run atris improve revisions`);
1719
+ }
1720
+ } catch {
1721
+ // Silent: the gauge never blocks boot.
1722
+ }
1723
+
1712
1724
  let wikiNudge = '';
1713
1725
  try {
1714
1726
  wikiNudge = require('../lib/wiki').wikiMetabolismNudge(cwd);
@@ -2732,7 +2744,7 @@ if (command === 'init') {
2732
2744
  const subcommand = process.argv[3];
2733
2745
  const args = process.argv.slice(4);
2734
2746
  require('../commands/business').businessCommand(subcommand, ...args)
2735
- .then(() => process.exit(0))
2747
+ .then(() => process.exit(process.exitCode || 0))
2736
2748
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2737
2749
  } else if (command === 'soul') {
2738
2750
  const args = process.argv.slice(3);
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const escapeRegExp = require('../lib/escape-regexp');
3
4
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
4
5
  const { detectWorkspaceState, loadContext } = require('../lib/state-detection');
5
6
  const { readWikiStatus } = require('../lib/wiki');
@@ -13,10 +14,6 @@ const CLARITY_FIELDS = [
13
14
  { key: 'leash', label: 'Leash' },
14
15
  ];
15
16
 
16
- function escapeRegExp(value) {
17
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
- }
19
-
20
17
  function readClarityMarkdownProfile(mdPath) {
21
18
  try {
22
19
  const content = fs.readFileSync(mdPath, 'utf8');
@@ -4384,6 +4384,7 @@ async function runComputer(argv = process.argv.slice(3), deps = {}) {
4384
4384
  default:
4385
4385
  console.error(`Unknown subcommand: ${sub}`);
4386
4386
  console.log('Run: atris computer --help');
4387
+ process.exitCode = 1;
4387
4388
  }
4388
4389
  }
4389
4390
 
@@ -4399,4 +4400,23 @@ module.exports = {
4399
4400
  extractAttachedWorkspaceMismatch,
4400
4401
  contextForAttachedWorkspaceMismatch,
4401
4402
  printRecruitingLocalSyncOutcome,
4403
+ // Hermetic parsing/formatting layer, exported for test/computer.test.js.
4404
+ parseComputerOptions,
4405
+ parseComputerCreateArgs,
4406
+ computerCreateArgsHaveName,
4407
+ normalizeComputerType,
4408
+ formatComputerTypeList,
4409
+ parseComputerDeleteArgs,
4410
+ parseComputerCardArgs,
4411
+ renderComputerCard,
4412
+ renderComputerCardMarkdown,
4413
+ formatLeaseAge,
4414
+ formatWorkspaceRef,
4415
+ workspaceMatchesInput,
4416
+ resolveWorkspaceFromList,
4417
+ workspaceMatchesComputerType,
4418
+ looksLikeWorkspaceId,
4419
+ shellQuote,
4420
+ withoutRecruitingWrapperFlags,
4421
+ formatCloudSelection,
4402
4422
  };
@@ -348,6 +348,13 @@ function plural(n, word) {
348
348
  return `${n} ${word}${n === 1 ? '' : 's'}`;
349
349
  }
350
350
 
351
+ // Spell small counts as words so the vitals read as prose; digits above nine.
352
+ const SMALL_NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
353
+
354
+ function countWord(n) {
355
+ return n >= 0 && n < SMALL_NUMBER_WORDS.length ? SMALL_NUMBER_WORDS[n] : String(n);
356
+ }
357
+
351
358
  function formatReward(value) {
352
359
  const n = Number(value) || 0;
353
360
  if (Number.isInteger(n)) return String(n);
@@ -505,6 +512,26 @@ function collectImproveVitals(options = {}, deps = {}) {
505
512
  sentence: plainSentence(usageSentence),
506
513
  };
507
514
 
515
+ // The guarantee gauge: agent landings vs human fixes over the last 14 days.
516
+ // Collection is bounded by --since so a huge repo only walks a fortnight.
517
+ // No git history (not a repo, git missing) means the line is omitted silently.
518
+ let guarantee = null;
519
+ try {
520
+ const collect = deps.collectRevisionSignals || collectRevisionSignals;
521
+ const rev = collect(root, { days: 14, now: nowMs });
522
+ const landingsPhrase = `${countWord(rev.landings)} landing${rev.landings === 1 ? '' : 's'} this fortnight`;
523
+ const fixPhrase = `${countWord(rev.revised)} needed a human fix`;
524
+ guarantee = {
525
+ days: rev.days,
526
+ landings: rev.landings,
527
+ revised: rev.revised,
528
+ rate: rev.rate,
529
+ sentence: plainSentence(`${landingsPhrase}, ${fixPhrase}.`),
530
+ };
531
+ } catch {
532
+ guarantee = null;
533
+ }
534
+
508
535
  const sentences = [
509
536
  heartbeat.sentence,
510
537
  exploit.sentence,
@@ -512,6 +539,7 @@ function collectImproveVitals(options = {}, deps = {}) {
512
539
  excrete.sentence,
513
540
  ...(topOverdueSentence ? [`the top overdue loop says ${topOverdueSentence}`] : []),
514
541
  usage.sentence,
542
+ ...(guarantee ? [guarantee.sentence] : []),
515
543
  ];
516
544
  const groups = [
517
545
  [heartbeat.sentence, installNudge].filter(Boolean),
@@ -519,6 +547,7 @@ function collectImproveVitals(options = {}, deps = {}) {
519
547
  [explore.sentence],
520
548
  [excrete.sentence, ...(topOverdueSentence ? [`the top overdue loop says ${topOverdueSentence}`] : [])],
521
549
  [usage.sentence],
550
+ ...(guarantee ? [[guarantee.sentence]] : []),
522
551
  ];
523
552
 
524
553
  return {
@@ -529,6 +558,7 @@ function collectImproveVitals(options = {}, deps = {}) {
529
558
  explore,
530
559
  excrete,
531
560
  usage,
561
+ guarantee,
532
562
  install_nudge: installNudge,
533
563
  sentences,
534
564
  groups,
@@ -887,6 +917,190 @@ function formatImproveReport(result = {}) {
887
917
  return lines.join('\n');
888
918
  }
889
919
 
920
+ // ---------------------------------------------------------------------------
921
+ // atris improve revisions — the gauge for the north-star metric:
922
+ // operator revisions after landing = 0. an agent landing is a commit carrying
923
+ // the atris co-author trailer (atris-builder[bot]); if a human commit touches
924
+ // any of the same files within 72 hours, that landing failed the guarantee.
925
+ //
926
+ // renames are NOT followed: `git log --follow` is per-file and would cost one
927
+ // subprocess per file per landing, so a post-landing rename reads as "no
928
+ // overlap". that undercounts revisions slightly; accepted on purpose.
929
+
930
+ const REVISIONS_SCHEMA = 'atris.improve_revisions.v1';
931
+ const REVISION_WINDOW_HOURS = 72;
932
+ const REVISION_WINDOW_MS = REVISION_WINDOW_HOURS * 60 * 60 * 1000;
933
+ const AGENT_TRAILER_MARKER = 'atris-builder[bot]';
934
+ const DEFAULT_REVISIONS_DAYS = 14;
935
+
936
+ function parseRevisionsArgs(argv = []) {
937
+ const args = Array.isArray(argv) ? argv : [];
938
+ const opts = { days: DEFAULT_REVISIONS_DAYS, json: false, help: false };
939
+ for (let i = 0; i < args.length; i++) {
940
+ const a = args[i];
941
+ if (a === '--help' || a === '-h') { opts.help = true; continue; }
942
+ if (a === '--json') { opts.json = true; continue; }
943
+ if (a === '--days') { const v = Number(args[++i]); if (Number.isFinite(v) && v > 0) opts.days = Math.round(v); continue; }
944
+ if (a.startsWith('--days=')) { const v = Number(a.split('=')[1]); if (Number.isFinite(v) && v > 0) opts.days = Math.round(v); continue; }
945
+ }
946
+ return opts;
947
+ }
948
+
949
+ function gitLines(cwdRoot, gitArgs) {
950
+ const r = spawnSync('git', gitArgs, { cwd: cwdRoot, encoding: 'utf8' });
951
+ if (r.status !== 0) {
952
+ const err = new Error(String(r.stderr || `git ${gitArgs[0]} exited ${r.status}`).trim());
953
+ err.gitFailed = true;
954
+ throw err;
955
+ }
956
+ return String(r.stdout || '');
957
+ }
958
+
959
+ /**
960
+ * Files changed by one commit. Merge commits are attributed by their
961
+ * first-parent diff (what the merge actually brought onto the mainline);
962
+ * plain commits use diff-tree. Root commits list their initial files.
963
+ */
964
+ function commitFiles(cwdRoot, commit) {
965
+ const parents = commit.parents;
966
+ const out = parents.length >= 2
967
+ ? gitLines(cwdRoot, ['diff', '--name-only', `${commit.hash}^1`, commit.hash])
968
+ : gitLines(cwdRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', commit.hash]);
969
+ return out.split('\n').map((line) => line.trim()).filter(Boolean);
970
+ }
971
+
972
+ /**
973
+ * Read the last N days of history and pair every agent landing with the
974
+ * human commits that touched the same files within the 72-hour window.
975
+ */
976
+ function collectRevisionSignals(root, options = {}) {
977
+ const days = Number.isFinite(options.days) && options.days > 0 ? Math.round(options.days) : DEFAULT_REVISIONS_DAYS;
978
+ const nowMs = options.now != null ? new Date(options.now).getTime() : Date.now();
979
+ const sinceIso = new Date(nowMs - days * DAY_MS).toISOString();
980
+
981
+ let raw = '';
982
+ try {
983
+ raw = gitLines(root, ['log', `--since=${sinceIso}`, '--date=iso-strict', '--pretty=format:%H%x1f%P%x1f%aI%x1f%s%x1f%B%x1e']);
984
+ } catch (e) {
985
+ // a repo with no commits yet exits non-zero on `git log`; that is the
986
+ // empty-history case, not an error. anything else (not a repo) rethrows.
987
+ if (!/does not have any commits|bad default revision|unknown revision/i.test(e.message)) throw e;
988
+ raw = '';
989
+ }
990
+
991
+ const commits = raw.split('\x1e')
992
+ .map((chunk) => chunk.replace(/^\n/, ''))
993
+ .filter((chunk) => chunk.trim())
994
+ .map((chunk) => {
995
+ const [hash, parents, at, subject, body] = chunk.split('\x1f');
996
+ return {
997
+ hash: String(hash || '').trim(),
998
+ parents: String(parents || '').trim().split(/\s+/).filter(Boolean),
999
+ at: String(at || '').trim(),
1000
+ ms: timestampMs(at),
1001
+ subject: String(subject || '').trim(),
1002
+ isAgent: String(body || '').includes(AGENT_TRAILER_MARKER),
1003
+ };
1004
+ })
1005
+ .filter((c) => c.hash && c.ms != null);
1006
+
1007
+ const landings = commits.filter((c) => c.isAgent);
1008
+ // A merge that carries agent work onto the mainline is a landing action,
1009
+ // not a human correction; counting merges as revisions made the first live
1010
+ // reading say 85 percent on a healthy repo. Only single-parent human
1011
+ // commits count as revision signals.
1012
+ const humans = commits.filter((c) => !c.isAgent && c.parents.length < 2);
1013
+
1014
+ const filesCache = new Map();
1015
+ const filesOf = (commit) => {
1016
+ if (!filesCache.has(commit.hash)) filesCache.set(commit.hash, commitFiles(root, commit));
1017
+ return filesCache.get(commit.hash);
1018
+ };
1019
+
1020
+ const revisions = [];
1021
+ for (const landing of landings) {
1022
+ const landedFiles = new Set(filesOf(landing));
1023
+ if (!landedFiles.size) continue;
1024
+ const revisedBy = [];
1025
+ const touched = new Set();
1026
+ for (const human of humans) {
1027
+ if (human.ms <= landing.ms || human.ms > landing.ms + REVISION_WINDOW_MS) continue;
1028
+ const overlap = filesOf(human).filter((f) => landedFiles.has(f));
1029
+ if (!overlap.length) continue;
1030
+ revisedBy.push({ hash: human.hash, subject: human.subject, at: human.at });
1031
+ overlap.forEach((f) => touched.add(f));
1032
+ }
1033
+ if (revisedBy.length) {
1034
+ revisions.push({
1035
+ landing: { hash: landing.hash, subject: landing.subject, at: landing.at },
1036
+ revised_by: revisedBy,
1037
+ files: [...touched].sort(),
1038
+ });
1039
+ }
1040
+ }
1041
+
1042
+ return {
1043
+ schema: REVISIONS_SCHEMA,
1044
+ generated_at: new Date(nowMs).toISOString(),
1045
+ days,
1046
+ window_hours: REVISION_WINDOW_HOURS,
1047
+ landings: landings.length,
1048
+ revised: revisions.length,
1049
+ rate: landings.length ? revisions.length / landings.length : 0,
1050
+ revisions,
1051
+ };
1052
+ }
1053
+
1054
+ function listFilesPhrase(files = []) {
1055
+ const shown = files.slice(0, 3);
1056
+ const rest = files.length - shown.length;
1057
+ return rest > 0 ? `${shown.join(', ')} and ${plural(rest, 'more file')}` : shown.join(', ');
1058
+ }
1059
+
1060
+ function formatRevisionsReport(summary = {}) {
1061
+ const days = summary.days || DEFAULT_REVISIONS_DAYS;
1062
+ if (!summary.landings) {
1063
+ return `no agent landings found in the last ${plural(days, 'day')}. nothing to measure yet.`;
1064
+ }
1065
+ const lines = [];
1066
+ lines.push(`agent landings in the last ${plural(days, 'day')}: ${summary.landings}.`);
1067
+ lines.push(`landings a human then revised: ${summary.revised}.`);
1068
+ lines.push(`revision rate: ${Math.round((summary.rate || 0) * 100)} percent. the target is zero.`);
1069
+ for (const item of Array.isArray(summary.revisions) ? summary.revisions : []) {
1070
+ lines.push('');
1071
+ lines.push(`an agent landed "${plainSentence(item.landing.subject)}". a human then changed ${listFilesPhrase(item.files)} within ${REVISION_WINDOW_HOURS} hours.`);
1072
+ }
1073
+ return lines.join('\n');
1074
+ }
1075
+
1076
+ function showRevisionsHelp() {
1077
+ console.log(`atris improve revisions - measure operator revisions after landing
1078
+
1079
+ Usage:
1080
+ atris improve revisions [--days N] [--json]
1081
+
1082
+ Reads git history for the last N days (default ${DEFAULT_REVISIONS_DAYS}). A commit with the
1083
+ atris co-author trailer is an agent landing; a later human commit touching
1084
+ the same files within ${REVISION_WINDOW_HOURS} hours is a revision signal. The north-star
1085
+ metric is a revision rate of zero.`);
1086
+ }
1087
+
1088
+ function runRevisions(argv = []) {
1089
+ const opts = parseRevisionsArgs(argv);
1090
+ if (opts.help) { showRevisionsHelp(); return 0; }
1091
+ let summary;
1092
+ try {
1093
+ summary = collectRevisionSignals(process.cwd(), { days: opts.days });
1094
+ } catch (e) {
1095
+ console.log('this folder has no readable git history, so there are no landings to measure.');
1096
+ if (!e.gitFailed) console.error(` ${e.message}`);
1097
+ return 1;
1098
+ }
1099
+ if (opts.json) console.log(JSON.stringify(summary));
1100
+ else console.log(formatRevisionsReport(summary));
1101
+ return 0;
1102
+ }
1103
+
890
1104
  function showHelp() {
891
1105
  console.log(`atris improve - show the self-improvement metabolism vitals
892
1106
 
@@ -894,6 +1108,7 @@ Usage:
894
1108
  atris improve
895
1109
  atris improve --json
896
1110
  atris improve doctor [--json] [--fix] [--check <kind>]
1111
+ atris improve revisions [--days N] [--json]
897
1112
  atris improve tick [mode] [options]
898
1113
  atris improve [mode|history] [options]
899
1114
 
@@ -903,6 +1118,7 @@ Modes (positional or --mode):
903
1118
  delegate queue the tick for a local Claude Code session
904
1119
  history show the tick history (reward trend, credits, pass rate)
905
1120
  doctor scan loop receipts and optionally file one repair mission
1121
+ revisions measure operator revisions after agent landings (target: zero)
906
1122
 
907
1123
  Options:
908
1124
  --member <name> attribute the tick to a member (the loop's owner)
@@ -1074,6 +1290,9 @@ async function run(argv = [], deps = {}) {
1074
1290
  const result = runLoopDoctor(args.slice(1), deps);
1075
1291
  return result.check && !result.check.ok ? 1 : 0;
1076
1292
  }
1293
+ if (args[0] === 'revisions') {
1294
+ return runRevisions(args.slice(1));
1295
+ }
1077
1296
  if (isBareVitalsArgs(args)) {
1078
1297
  const vitals = (deps.collectImproveVitals || collectImproveVitals)({ workspace: process.cwd() }, deps);
1079
1298
  if (args.includes('--json')) console.log(JSON.stringify(vitals));
@@ -1129,6 +1348,8 @@ module.exports = {
1129
1348
  formatTickHistory,
1130
1349
  improveApiPath,
1131
1350
  runLoopDoctor,
1351
+ collectRevisionSignals,
1352
+ formatRevisionsReport,
1132
1353
  runLocalFallback,
1133
1354
  summarizeLocalMissionRun,
1134
1355
  LOCAL_FALLBACK_ARGS,
@@ -6,6 +6,7 @@ const crypto = require('crypto');
6
6
  const readline = require('readline');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
9
+ const escapeRegExp = require('../lib/escape-regexp');
9
10
  const {
10
11
  appendBriefRecord,
11
12
  stampBriefOutcome,
@@ -1901,7 +1902,7 @@ function missionLastLanding(mission, root = process.cwd()) {
1901
1902
 
1902
1903
  function missionLandingChangedIsGenericTick(mission, changed) {
1903
1904
  const text = String(changed || '').trim();
1904
- const objective = String(mission?.objective || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1905
+ const objective = escapeRegExp(mission?.objective || '');
1905
1906
  if (!objective) return false;
1906
1907
  return new RegExp(`^${objective} recorded tick \\d+\\.$`).test(text);
1907
1908
  }
@@ -221,10 +221,21 @@ function parseResponseData(result) {
221
221
  try { return JSON.parse(text); } catch { return null; }
222
222
  }
223
223
 
224
+ function validationDetail(items) {
225
+ const messages = items.map((item) => {
226
+ if (!item || typeof item !== 'object') return String(item);
227
+ const message = item.msg || item.message || item.type || 'validation failed';
228
+ const location = Array.isArray(item.loc) ? item.loc.join('.') : '';
229
+ return location ? `${location}: ${message}` : message;
230
+ });
231
+ return messages.join('; ');
232
+ }
233
+
224
234
  function errorDetail(result) {
225
235
  const data = parseResponseData(result);
226
236
  if (data && typeof data === 'object') {
227
237
  const detail = data.detail || data.error || data.message;
238
+ if (Array.isArray(detail)) return validationDetail(detail);
228
239
  if (detail) return typeof detail === 'string' ? detail : JSON.stringify(detail);
229
240
  }
230
241
  return responseText(result).trim() || 'request failed';
@@ -314,9 +325,13 @@ async function uploadPages(sitesUrl, slug, pages, token, deps = {}) {
314
325
  const url = `${sitesUrl}/${slug}/pages`;
315
326
  for (let start = 0; start < pages.length; start += BATCH_SIZE) {
316
327
  const batch = pages.slice(start, start + BATCH_SIZE);
317
- const result = await requestJson('PUT', url, token, {
318
- pages: batch.map((page) => pagePayload(page, fileSystem)),
319
- }, deps);
328
+ const result = await requestJson(
329
+ 'PUT',
330
+ url,
331
+ token,
332
+ batch.map((page) => pagePayload(page, fileSystem)),
333
+ deps,
334
+ );
320
335
  if (result.status < 200 || result.status >= 300) throw requestError('PUT', url, result);
321
336
  for (const page of batch) log(` published ${page.path} (${formatBytes(page.size)})`);
322
337
  }
@@ -846,13 +861,13 @@ async function run(argv, deps = {}) {
846
861
  log(`\n deploying ${pages.length} file${pages.length === 1 ? '' : 's'} to ${liveUrl}`);
847
862
  try {
848
863
  await createSite(sitesUrl, options.name, options.spa, credentials.token, { ...deps, log });
864
+ await registerSubdomain(options.name, { ...deps, log });
849
865
  await uploadPages(sitesUrl, options.name, pages, credentials.token, { ...deps, log });
850
866
  } catch (error) {
851
867
  errorLog(` deploy failed: ${error.message}`);
852
868
  return 1;
853
869
  }
854
870
 
855
- await registerSubdomain(options.name, { ...deps, log });
856
871
  log(`\n live at ${liveUrl}`);
857
872
  return 0;
858
873
  }
package/commands/slop.js CHANGED
@@ -18,6 +18,7 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { execFileSync } = require('child_process');
21
+ const escapeRegExp = require('../lib/escape-regexp');
21
22
 
22
23
  const SCAN_EXTS = new Set(['.css', '.scss', '.sass', '.less', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.html', '.vue', '.svelte', '.astro',
23
24
  '.md', '.mdx', '.txt']); // prose too: the voice doctrine (em-dash, hype-copy) is enforceable, not just advice
@@ -418,7 +419,7 @@ function findDeadCode(root = process.cwd(), opts = {}) {
418
419
  const allRepoJs = [...new Set([...listJsFiles(root)])];
419
420
  const mentionedBy = (file, predicate) => {
420
421
  const stem = path.basename(file).replace(/\.(js|mjs|cjs)$/, '');
421
- const re = new RegExp(`['"\`/]${stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.js)?['"\`]`);
422
+ const re = new RegExp(`['"\`/]${escapeRegExp(stem)}(?:\\.js)?['"\`]`);
422
423
  return allRepoJs.some((f) => {
423
424
  if (f === file || !predicate(f)) return false;
424
425
  try { return re.test(fs.readFileSync(f, 'utf8')); } catch { return false; }
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { hasFlag, readFlag } = require('../lib/arg-parser');
6
+ const escapeRegExp = require('../lib/escape-regexp');
6
7
  const { runGit: spawnGit } = require('../lib/git-spawn');
7
8
  const { collectBoard } = require('./land');
8
9
  const { listWorktrees } = require('./worktree');
@@ -236,7 +237,7 @@ function sanitizeAgent(value) {
236
237
  }
237
238
 
238
239
  function stripLeadingAgent(summary, agent) {
239
- const cleanAgent = String(agent || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
240
+ const cleanAgent = escapeRegExp(agent || '');
240
241
  if (!cleanAgent) return summary;
241
242
  return String(summary || '').replace(new RegExp(`^${cleanAgent}\\s+`, 'i'), '');
242
243
  }
@@ -22,6 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
24
  const { gitChangedLines } = require('./slop'); // reuse the diff parser — DRY
25
+ const escapeRegExp = require('../lib/escape-regexp');
25
26
 
26
27
  const CODE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.vue', '.svelte', '.astro', '.html']);
27
28
  const TEXT_EXTS = new Set([...CODE_EXTS, '.md', '.mdx', '.txt']);
@@ -232,7 +233,7 @@ function check(argv) {
232
233
  }
233
234
  const regAbs = path.resolve(process.cwd(), REGISTRY_FILE);
234
235
 
235
- const matchers = reg.terms.map((t) => ({ ...t, re: new RegExp(`\\b${t.ban.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') }));
236
+ const matchers = reg.terms.map((t) => ({ ...t, re: new RegExp(`\\b${escapeRegExp(t.ban)}\\b`, 'i') }));
236
237
  const findings = [];
237
238
  for (const file of files) {
238
239
  if (path.resolve(file) === regAbs) continue; // never flag the registry itself
package/commands/team.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
 
5
6
  const { canonicalEngineName } = require('../lib/engine-registry');
@@ -115,12 +116,83 @@ function renderTeamRoster(roster) {
115
116
  .join('\n');
116
117
  }
117
118
 
119
+ // The pruning pass keeps the team lean like a real company: it flags members
120
+ // with no recent signal, and it never deletes anything. A signal is the newest
121
+ // of MEMBER.md, any logs/*.md, or a mission the member owns that is still
122
+ // active or running.
123
+ const PRUNE_ACTIVE_MISSION_STATUSES = new Set(['active', 'running']);
124
+ const DEFAULT_PRUNE_DAYS = 30;
125
+ const DAY_MS = 24 * 60 * 60 * 1000;
126
+
127
+ function newestSignalMs(member) {
128
+ const times = [];
129
+ const stamp = (file) => {
130
+ try { times.push(fs.statSync(file).mtimeMs); } catch { /* missing file is just no signal */ }
131
+ };
132
+ if (member?.path) stamp(member.path);
133
+ if (member?.dir) {
134
+ const logsDir = path.join(member.dir, 'logs');
135
+ let entries = [];
136
+ try { entries = fs.readdirSync(logsDir); } catch { entries = []; }
137
+ for (const entry of entries) {
138
+ if (entry.endsWith('.md')) stamp(path.join(logsDir, entry));
139
+ }
140
+ }
141
+ return times.length ? Math.max(...times) : 0;
142
+ }
143
+
144
+ function collectTeamPrune(deps = {}) {
145
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
146
+ const days = Number.isFinite(deps.days) && deps.days > 0 ? deps.days : DEFAULT_PRUNE_DAYS;
147
+ const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
148
+ const activeOwners = new Set();
149
+ for (const mission of collectMissions(root, deps)) {
150
+ if (!PRUNE_ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) continue;
151
+ const owner = String(mission?.owner || mission?.member || '').trim().toLowerCase();
152
+ if (owner) activeOwners.add(owner);
153
+ }
154
+ const quiet = [];
155
+ let activeCount = 0;
156
+ for (const member of collectMembers(root, deps)) {
157
+ const name = String(member?.name || '').trim().toLowerCase();
158
+ if (!name) continue;
159
+ const signalMs = newestSignalMs(member);
160
+ if (activeOwners.has(name) || (signalMs && nowMs - signalMs < days * DAY_MS)) {
161
+ activeCount += 1;
162
+ continue;
163
+ }
164
+ quiet.push({
165
+ name,
166
+ days_quiet: signalMs ? Math.floor((nowMs - signalMs) / DAY_MS) : null,
167
+ last_signal: signalMs ? new Date(signalMs).toISOString() : null,
168
+ });
169
+ }
170
+ quiet.sort((a, b) => a.name.localeCompare(b.name));
171
+ return { quiet, active_count: activeCount };
172
+ }
173
+
174
+ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
175
+ if (!report.quiet.length && !report.active_count) {
176
+ return 'no team members yet. create one with: atris member create <name> --role="..."';
177
+ }
178
+ if (!report.quiet.length) {
179
+ return `everyone on the team has a signal newer than ${days} days. nothing to prune.`;
180
+ }
181
+ const lines = report.quiet.map((entry) => (entry.days_quiet === null
182
+ ? `${entry.name} has no recorded activity; keep, hand off, or retire.`
183
+ : `${entry.name} has been quiet for ${entry.days_quiet} days; keep, hand off, or retire.`));
184
+ lines.push(`${report.active_count} member${report.active_count === 1 ? ' is' : 's are'} still active. nothing was deleted; this is a report.`);
185
+ return lines.join('\n');
186
+ }
187
+
118
188
  function helpText() {
119
189
  return [
120
190
  'atris team - one team view: every member, their role, and any engine running their work',
121
191
  'atris team presence - show who is awake and what they are doing',
192
+ 'atris team prune - flag members with no recent activity; deletes nothing',
122
193
  '',
123
194
  'usage: atris team [roster|presence] [--json]',
195
+ 'usage: atris team prune [--days N] [--json]',
124
196
  ].join('\n');
125
197
  }
126
198
 
@@ -129,6 +201,27 @@ function teamCommand(args = [], deps = {}) {
129
201
  (deps.write || process.stdout.write.bind(process.stdout))(`${helpText()}\n`);
130
202
  return 0;
131
203
  }
204
+ if (args[0] === 'prune') {
205
+ const rest = args.slice(1);
206
+ let days = DEFAULT_PRUNE_DAYS;
207
+ let json = false;
208
+ let bad = false;
209
+ for (let i = 0; i < rest.length; i += 1) {
210
+ const arg = rest[i];
211
+ if (arg === '--json') { json = true; continue; }
212
+ if (arg === '--days') { i += 1; days = Number(rest[i]); continue; }
213
+ if (arg.startsWith('--days=')) { days = Number(arg.slice('--days='.length)); continue; }
214
+ bad = true;
215
+ }
216
+ if (bad || !Number.isFinite(days) || days <= 0) {
217
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team prune [--days N] [--json]\n');
218
+ return 2;
219
+ }
220
+ const report = deps.prune || collectTeamPrune({ ...deps, days });
221
+ const output = json ? JSON.stringify(report, null, 2) : renderTeamPrune(report, days);
222
+ (deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
223
+ return 0;
224
+ }
132
225
  const rosterArgs = args.filter((arg) => arg !== 'roster');
133
226
  if (args[0] !== 'presence' && rosterArgs.every((arg) => arg === '--json')) {
134
227
  const roster = deps.roster || collectTeamRoster(deps);
@@ -139,7 +232,7 @@ function teamCommand(args = [], deps = {}) {
139
232
  return 0;
140
233
  }
141
234
  if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
142
- (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence] [--json]\n');
235
+ (deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json]\n');
143
236
  return 2;
144
237
  }
145
238
  const presence = deps.presence || collectTeamPresence(deps);
@@ -150,4 +243,4 @@ function teamCommand(args = [], deps = {}) {
150
243
  return 0;
151
244
  }
152
245
 
153
- module.exports = { collectMissions, collectTasks, collectTeamPresence, collectTeamRoster, renderTeamRoster, teamCommand };
246
+ module.exports = { collectMissions, collectTasks, collectTeamPresence, collectTeamPrune, collectTeamRoster, renderTeamPrune, renderTeamRoster, teamCommand };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const escapeRegExp = require('./escape-regexp');
5
6
 
6
7
  const ACTIVE_STATUSES = new Set(['observed', 'open', 'attempted']);
7
8
  const KEYWORD_STOP_WORDS = new Set([
@@ -80,7 +81,7 @@ function slugKeywords(slug) {
80
81
  }
81
82
 
82
83
  function keywordAppears(text, keyword) {
83
- const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
84
+ const escaped = escapeRegExp(keyword);
84
85
  return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(text);
85
86
  }
86
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.40.0",
3
+ "version": "3.42.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {