atris 3.37.1 → 3.40.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.
Files changed (164) hide show
  1. package/AGENTS.md +25 -6
  2. package/README.md +2 -0
  3. package/atris/PERSONA.md +11 -11
  4. package/atris.md +17 -2
  5. package/ax +160 -34
  6. package/bin/atris.js +78 -6
  7. package/commands/activate.js +16 -3
  8. package/commands/agent-spawn.js +13 -18
  9. package/commands/autoland.js +76 -47
  10. package/commands/autopilot-front.js +0 -4
  11. package/commands/autopilot.js +0 -12
  12. package/commands/bench.js +10 -12
  13. package/commands/brain.js +0 -7
  14. package/commands/brief.js +0 -2
  15. package/commands/business-sync.js +0 -1
  16. package/commands/business.js +452 -12
  17. package/commands/chat-scan.js +5 -7
  18. package/commands/clean.js +31 -2
  19. package/commands/close.js +46 -11
  20. package/commands/cloud.js +0 -1
  21. package/commands/codex-goal.js +8 -12
  22. package/commands/computer.js +6 -5
  23. package/commands/console.js +64 -11
  24. package/commands/decide.js +166 -0
  25. package/commands/deck.js +30 -12
  26. package/commands/dream.js +0 -7
  27. package/commands/drill.js +14 -25
  28. package/commands/engine.js +206 -26
  29. package/commands/experiments.js +0 -4
  30. package/commands/fleet-report.js +12 -4
  31. package/commands/game.js +261 -0
  32. package/commands/gm.js +17 -7
  33. package/commands/goal.js +0 -6
  34. package/commands/harvest.js +1 -5
  35. package/commands/improve.js +29 -8
  36. package/commands/init.js +41 -37
  37. package/commands/integrations.js +0 -7
  38. package/commands/land.js +77 -20
  39. package/commands/launchpad.js +1 -16
  40. package/commands/lesson.js +17 -7
  41. package/commands/lifecycle.js +5 -5
  42. package/commands/log.js +55 -5
  43. package/commands/loop-front.js +0 -2
  44. package/commands/loops.js +6 -5
  45. package/commands/meet.js +0 -2
  46. package/commands/member.js +622 -573
  47. package/commands/mission.js +790 -314
  48. package/commands/next.js +0 -1
  49. package/commands/now.js +0 -12
  50. package/commands/one-lap.js +8 -8
  51. package/commands/orb.js +0 -4
  52. package/commands/pack-craft.js +0 -1
  53. package/commands/pack.js +3599 -424
  54. package/commands/play.js +6 -6
  55. package/commands/probe.js +12 -6
  56. package/commands/pulse.js +88 -64
  57. package/commands/push.js +0 -4
  58. package/commands/radar.js +14 -11
  59. package/commands/recover.js +340 -0
  60. package/commands/release.js +10 -9
  61. package/commands/report.js +0 -1
  62. package/commands/router.js +320 -0
  63. package/commands/run-front.js +0 -2
  64. package/commands/search.js +0 -3
  65. package/commands/site-deploy.js +870 -0
  66. package/commands/site.js +11 -2
  67. package/commands/skill.js +7 -2
  68. package/commands/slop.js +237 -7
  69. package/commands/status.js +6 -0
  70. package/commands/stream.js +4 -20
  71. package/commands/study.js +0 -3
  72. package/commands/sync-checkout.js +51 -0
  73. package/commands/sync.js +11 -40
  74. package/commands/task.js +999 -595
  75. package/commands/taste.js +101 -0
  76. package/commands/teach.js +86 -0
  77. package/commands/team.js +83 -3
  78. package/commands/unknowns.js +0 -10
  79. package/commands/update.js +0 -1
  80. package/commands/vercel.js +4 -2
  81. package/commands/visualize.js +0 -5
  82. package/commands/voice.js +195 -0
  83. package/commands/watch.js +1 -28
  84. package/commands/wiki.js +42 -15
  85. package/commands/wish.js +0 -1
  86. package/commands/workflow.js +2 -2
  87. package/commands/worktree.js +88 -28
  88. package/commands/xp.js +28 -30
  89. package/commands/youtube.js +0 -2
  90. package/lib/accept-verify-gate.js +126 -0
  91. package/lib/activity-stream.js +0 -2
  92. package/lib/arg-parser.js +41 -0
  93. package/lib/auto-accept-certified.js +261 -3
  94. package/lib/autoland.js +66 -3
  95. package/lib/ax-auto-lane.js +68 -7
  96. package/lib/bench/context.js +0 -2
  97. package/lib/bench/report.js +0 -1
  98. package/lib/bench/runner.js +18 -11
  99. package/lib/brief-ledger.js +0 -6
  100. package/lib/business-simulate.js +382 -0
  101. package/lib/chat-log-scan.js +0 -5
  102. package/lib/checkout-sync.js +52 -0
  103. package/lib/claude-boot-block.js +48 -0
  104. package/lib/cloud-mission.js +0 -6
  105. package/lib/codex-flight.js +0 -1
  106. package/lib/conductor-artifacts.js +25 -0
  107. package/lib/context-gatherer.js +7 -2
  108. package/lib/deck-compose.js +0 -1
  109. package/lib/deck-history.js +0 -1
  110. package/lib/deck-layout.js +0 -5
  111. package/lib/deck-review.js +0 -7
  112. package/lib/deck-schema.js +0 -1
  113. package/lib/default-verifier.js +0 -2
  114. package/lib/engine-registry.js +157 -28
  115. package/lib/experiments/daily.js +0 -6
  116. package/lib/falsifier-probe.js +84 -0
  117. package/lib/fleet.js +275 -42
  118. package/lib/functional-owner.js +0 -3
  119. package/lib/git-spawn.js +15 -0
  120. package/lib/inspect-fields.js +0 -1
  121. package/lib/json-file.js +37 -0
  122. package/lib/known-commands.js +2 -2
  123. package/lib/learnings.js +0 -2
  124. package/lib/lesson-preflight.js +146 -0
  125. package/lib/loop-doctor.js +11 -19
  126. package/lib/manifest.js +0 -2
  127. package/lib/member-alive.js +0 -1
  128. package/lib/member-switches.js +0 -5
  129. package/lib/mission-artifact.js +0 -1
  130. package/lib/mission-human-asks.js +28 -0
  131. package/lib/mission-protected-lane.js +296 -0
  132. package/lib/mission-room.js +0 -2
  133. package/lib/mission-runtime-loop.js +0 -1
  134. package/lib/next-moves.js +0 -18
  135. package/lib/official-cli-integration.js +47 -6
  136. package/lib/orb-context.js +14 -4
  137. package/lib/orb-scorecard.js +0 -1
  138. package/lib/outbound-send-gate.js +0 -6
  139. package/lib/pack-capabilities.js +685 -0
  140. package/lib/permission-grants.js +0 -6
  141. package/lib/policy-lessons.js +0 -2
  142. package/lib/pulse.js +68 -15
  143. package/lib/review-integrity.js +0 -2
  144. package/lib/router-brain.js +51 -3
  145. package/lib/runner-command.js +0 -10
  146. package/lib/runtime-bootstrap.js +0 -1
  147. package/lib/scorecard.js +0 -1
  148. package/lib/self-drive.js +45 -14
  149. package/lib/state-detection.js +0 -6
  150. package/lib/task-db.js +137 -7
  151. package/lib/task-decision.js +50 -0
  152. package/lib/taste-lessons.js +153 -0
  153. package/lib/team-presence.js +0 -1
  154. package/lib/tool-result-encode.js +17 -1
  155. package/lib/voice-gate.js +129 -2
  156. package/lib/wiki.js +152 -9
  157. package/lib/wish-audit.js +21 -7
  158. package/lib/wish-delegate.js +27 -21
  159. package/lib/wish-stats.js +0 -5
  160. package/lib/wish-store.js +0 -1
  161. package/lib/workspace-safety.js +0 -2
  162. package/lib/zip.js +95 -8
  163. package/package.json +2 -1
  164. package/templates/business-starter/persona.md +9 -0
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // Decision rows are human judgment calls, not agent work. Detect them from
4
+ // tags already in the data (primary tag, tags[], metadata.tags). The live
5
+ // marker is needs-human (CLI-879); decision is the same hold under a clearer
6
+ // name. Autonomous pickers must refuse these; list renderers must show them.
7
+
8
+ const DECISION_HOLD_TAGS = new Set([
9
+ 'needs-human',
10
+ 'needshuman',
11
+ 'decision',
12
+ ]);
13
+
14
+ const DECISION_MARKER = '[decision]';
15
+ const DECISION_REFUSE_REASON = 'decision row: human judgment required';
16
+
17
+ function normalizeDecisionTag(value) {
18
+ return String(value == null ? '' : value).trim().toLowerCase().replace(/_/g, '-');
19
+ }
20
+
21
+ function taskTagTokens(task) {
22
+ if (!task || typeof task !== 'object') return [];
23
+ const fromTags = Array.isArray(task.tags) ? task.tags : [];
24
+ const fromTag = task.tag ? [task.tag] : [];
25
+ const fromMeta = task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
26
+ const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
27
+ return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle]
28
+ .map(normalizeDecisionTag)
29
+ .filter(Boolean);
30
+ }
31
+
32
+ function isDecisionHoldTag(tag) {
33
+ return DECISION_HOLD_TAGS.has(normalizeDecisionTag(tag));
34
+ }
35
+
36
+ function isDecisionTask(task) {
37
+ return taskTagTokens(task).some(isDecisionHoldTag);
38
+ }
39
+
40
+ function decisionMarkerFor(task) {
41
+ return isDecisionTask(task) ? DECISION_MARKER : '';
42
+ }
43
+
44
+ module.exports = {
45
+ DECISION_REFUSE_REASON,
46
+ taskTagTokens,
47
+ isDecisionHoldTag,
48
+ isDecisionTask,
49
+ decisionMarkerFor,
50
+ };
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const VERDICTS = new Set(['keep', 'kill', 'more']);
7
+ const SCOPES = new Set(['writing', 'design', 'code', 'any']);
8
+ const STOP_WORDS = new Set([
9
+ 'and', 'are', 'because', 'for', 'from', 'has', 'have', 'into', 'its', 'not', 'of', 'on', 'or',
10
+ 'that', 'the', 'their', 'this', 'to', 'was', 'were', 'with', 'without', 'you', 'your',
11
+ ]);
12
+
13
+ function tastePath(root) {
14
+ return path.join(root, 'atris', 'taste.json');
15
+ }
16
+
17
+ function readTaste(root) {
18
+ try {
19
+ const parsed = JSON.parse(fs.readFileSync(tastePath(root), 'utf8'));
20
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function slugify(subject) {
27
+ return String(subject || '')
28
+ .normalize('NFKD')
29
+ .replace(/[\u0300-\u036f]/g, '')
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '')
33
+ .slice(0, 80)
34
+ .replace(/-+$/g, '');
35
+ }
36
+
37
+ function validEntry(entry) {
38
+ return entry
39
+ && typeof entry === 'object'
40
+ && VERDICTS.has(entry.verdict)
41
+ && typeof entry.subject === 'string'
42
+ && entry.subject.trim()
43
+ && typeof entry.why === 'string'
44
+ && entry.why.trim()
45
+ && SCOPES.has(entry.scope)
46
+ && typeof entry.added === 'string';
47
+ }
48
+
49
+ function entriesFrom(store) {
50
+ return Object.entries(store)
51
+ .filter(([, entry]) => validEntry(entry))
52
+ .map(([slug, entry]) => ({ slug, ...entry }));
53
+ }
54
+
55
+ function assertChoice(value, allowed, field) {
56
+ if (!allowed.has(value)) throw new Error(`${field} must be one of: ${[...allowed].join(', ')}`);
57
+ }
58
+
59
+ function addTaste({
60
+ verdict,
61
+ subject,
62
+ why,
63
+ scope = 'any',
64
+ example,
65
+ added,
66
+ root = process.cwd(),
67
+ } = {}) {
68
+ const cleanVerdict = String(verdict || '').trim().toLowerCase();
69
+ const cleanSubject = String(subject || '').trim();
70
+ const cleanWhy = String(why || '').trim();
71
+ const cleanScope = String(scope || 'any').trim().toLowerCase();
72
+ const cleanExample = example === undefined ? '' : String(example).trim();
73
+ const cleanAdded = String(added || '').trim();
74
+
75
+ assertChoice(cleanVerdict, VERDICTS, 'verdict');
76
+ assertChoice(cleanScope, SCOPES, 'scope');
77
+ if (!cleanSubject) throw new Error('subject is required');
78
+ if (!cleanWhy) throw new Error('why is required');
79
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(cleanAdded)) throw new Error('added must be an ISO date');
80
+
81
+ const baseSlug = slugify(cleanSubject);
82
+ if (!baseSlug) throw new Error('subject must contain letters or numbers');
83
+
84
+ const store = readTaste(root);
85
+ let slug = baseSlug;
86
+ let suffix = 2;
87
+ while (store[slug] && store[slug].subject !== cleanSubject) {
88
+ slug = `${baseSlug}-${suffix}`;
89
+ suffix += 1;
90
+ }
91
+
92
+ const entry = {
93
+ verdict: cleanVerdict,
94
+ subject: cleanSubject,
95
+ why: cleanWhy,
96
+ scope: cleanScope,
97
+ ...(cleanExample ? { example: cleanExample } : {}),
98
+ added: cleanAdded,
99
+ };
100
+ store[slug] = entry;
101
+
102
+ const file = tastePath(root);
103
+ fs.mkdirSync(path.dirname(file), { recursive: true });
104
+ fs.writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
105
+ return { slug, ...entry };
106
+ }
107
+
108
+ function listTaste({ root = process.cwd(), scope } = {}) {
109
+ const cleanScope = scope === undefined ? null : String(scope).trim().toLowerCase();
110
+ if (cleanScope) assertChoice(cleanScope, SCOPES, 'scope');
111
+ return entriesFrom(readTaste(root))
112
+ .filter((entry) => !cleanScope || cleanScope === 'any' || entry.scope === 'any' || entry.scope === cleanScope)
113
+ .sort((left, right) => right.added.localeCompare(left.added) || left.slug.localeCompare(right.slug));
114
+ }
115
+
116
+ function keywords(text) {
117
+ return [...new Set(String(text || '').toLowerCase().split(/[^a-z0-9]+/)
118
+ .filter((word) => word.length >= 3 && !STOP_WORDS.has(word)))];
119
+ }
120
+
121
+ function matchTaste({ briefText = '', scope = 'any', root = process.cwd() } = {}) {
122
+ const cleanScope = SCOPES.has(String(scope || '').toLowerCase())
123
+ ? String(scope).toLowerCase()
124
+ : 'any';
125
+ const briefWords = new Set(keywords(briefText));
126
+ if (!briefWords.size) return [];
127
+
128
+ const matches = [];
129
+ for (const entry of entriesFrom(readTaste(root))) {
130
+ if (cleanScope !== 'any' && entry.scope !== 'any' && entry.scope !== cleanScope) continue;
131
+
132
+ const subjectMatches = keywords(`${entry.slug} ${entry.subject}`).filter((word) => briefWords.has(word));
133
+ const whyMatches = keywords(entry.why).filter((word) => briefWords.has(word));
134
+ const exampleMatches = keywords(entry.example || '').filter((word) => briefWords.has(word));
135
+ const overlap = new Set([...subjectMatches, ...whyMatches, ...exampleMatches]);
136
+ if (!overlap.size) continue;
137
+
138
+ const scopeScore = entry.scope === cleanScope ? 10_000 : entry.scope === 'any' ? 5_000 : 0;
139
+ const score = scopeScore
140
+ + (subjectMatches.length * 300)
141
+ + (exampleMatches.length * 200)
142
+ + (whyMatches.length * 100)
143
+ + [...overlap].reduce((total, word) => total + word.length, 0);
144
+ matches.push({ ...entry, why_matched: `brief mentions ${[...overlap].join(', ')}`, score });
145
+ }
146
+
147
+ return matches
148
+ .sort((left, right) => right.score - left.score || left.slug.localeCompare(right.slug))
149
+ .slice(0, 5)
150
+ .map(({ score, ...entry }) => entry);
151
+ }
152
+
153
+ module.exports = { addTaste, listTaste, matchTaste };
@@ -255,6 +255,5 @@ module.exports = {
255
255
  ACTIVE_TASK_STATES,
256
256
  DEFAULT_FRESHNESS_WINDOW_MS,
257
257
  buildTeamPresence,
258
- operatorSentence,
259
258
  renderTeamPresence,
260
259
  };
@@ -4,4 +4,20 @@ function encodeToolResult(result) {
4
4
  return Buffer.from(JSON.stringify(result), 'utf8').toString('base64');
5
5
  }
6
6
 
7
- module.exports = { encodeToolResult };
7
+ function toolResultEncodingEnabled(env = process.env) {
8
+ return env.ATRIS_TOOL_RESULT_B64 !== '0';
9
+ }
10
+
11
+ function buildToolResultBody(callId, result, env = process.env) {
12
+ if (!toolResultEncodingEnabled(env)) {
13
+ return { call_id: callId, result };
14
+ }
15
+
16
+ return {
17
+ call_id: callId,
18
+ result: encodeToolResult(result),
19
+ output_encoding: 'base64',
20
+ };
21
+ }
22
+
23
+ module.exports = { buildToolResultBody, encodeToolResult, toolResultEncodingEnabled };
package/lib/voice-gate.js CHANGED
@@ -8,6 +8,17 @@ const {
8
8
  } = require('./autoland');
9
9
 
10
10
  const EM_DASH = /\u2014/;
11
+ const JARGON = /\b(?:idempotent|deterministic|substrate|orchestration|invariant|canonical|materialize|heuristic)\b/gi;
12
+ const JARGON_SUGGESTIONS = {
13
+ idempotent: 'safe to repeat',
14
+ deterministic: 'predictable',
15
+ substrate: 'base',
16
+ orchestration: 'coordination',
17
+ invariant: 'rule that must stay true',
18
+ canonical: 'standard',
19
+ materialize: 'create',
20
+ heuristic: 'rule of thumb',
21
+ };
11
22
  const NUMBER_WORDS = [
12
23
  'no', 'one', 'two', 'three', 'four', 'five', 'six',
13
24
  'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
@@ -30,7 +41,7 @@ function findingsFor(text, pattern, rule, why) {
30
41
  return findings;
31
42
  }
32
43
 
33
- function scanText(text) {
44
+ function scanWords(text) {
34
45
  const value = String(text || '');
35
46
  const findings = findingsFor(value, EM_DASH, 'em-dash', 'use plain punctuation');
36
47
  if (hasAgentJargon(value)) {
@@ -62,6 +73,55 @@ function scanText(text) {
62
73
  return findings;
63
74
  }
64
75
 
76
+ function paragraphsFor(text) {
77
+ return String(text || '')
78
+ .trim()
79
+ .split(/\r?\n(?:[ \t]*\r?\n)+/)
80
+ .map(paragraph => paragraph.trim())
81
+ .filter(Boolean);
82
+ }
83
+
84
+ function sentenceCount(text) {
85
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
86
+ if (!value) return 0;
87
+ const endings = value.match(/[.!?]+(?=\s|$)/g) || [];
88
+ return endings.length + (/[.!?]+$/.test(value) ? 0 : 1);
89
+ }
90
+
91
+ function scanShape(text) {
92
+ const paragraphs = paragraphsFor(text);
93
+ const findings = [];
94
+ for (const paragraph of paragraphs) {
95
+ if (sentenceCount(paragraph) > 2) {
96
+ findings.push({
97
+ rule: 'dense-block',
98
+ why: 'add a blank line after every two sentences',
99
+ snippet: paragraph,
100
+ });
101
+ }
102
+ }
103
+ if (paragraphs.length > 3) {
104
+ findings.push({
105
+ rule: 'too-many-ideas',
106
+ why: 'split this into messages with no more than three paragraphs each',
107
+ snippet: paragraphs.slice(3).join('\n\n'),
108
+ });
109
+ }
110
+ for (const match of String(text || '').matchAll(JARGON)) {
111
+ const suggestion = JARGON_SUGGESTIONS[match[0].toLowerCase()];
112
+ findings.push({
113
+ rule: 'jargon',
114
+ why: `replace "${match[0]}" with "${suggestion}"`,
115
+ snippet: match[0],
116
+ });
117
+ }
118
+ return findings;
119
+ }
120
+
121
+ function scanText(text) {
122
+ return [...scanWords(text), ...scanShape(text)];
123
+ }
124
+
65
125
  function cleanEmDashes(text) {
66
126
  return String(text || '')
67
127
  .replace(/\s*\u2014\s*/g, ', ')
@@ -86,13 +146,14 @@ function titleCanReplaceId(title) {
86
146
  }
87
147
 
88
148
  function gateForHuman(text, opts = {}) {
149
+ const shapeIssues = scanShape(text);
89
150
  let cleaned = cleanEmDashes(text);
90
151
  cleaned = dejargon(cleaned, { preserveTickets: true });
91
152
  if (titleCanReplaceId(opts.title)) {
92
153
  cleaned = cleaned.replace(globalPattern(voicePatterns.rawUlid), '');
93
154
  }
94
155
  cleaned = cleanAfterRemoval(cleaned);
95
- const issues = scanText(cleaned);
156
+ const issues = [...scanWords(cleaned), ...shapeIssues];
96
157
  return { ok: issues.length === 0, text: cleaned, issues };
97
158
  }
98
159
 
@@ -154,10 +215,76 @@ function isRetiredFillerReason(text) {
154
215
  return RETIRED_FILLER_REASON.test(String(text || '').replace(/\s+/g, ' ').trim());
155
216
  }
156
217
 
218
+ // One table turns every landing/refusal reason code into a plain sentence a
219
+ // fried reader can act on: what happened, and what unblocks it. JSON output
220
+ // keeps the raw codes; only human text goes through here. Fragments carry no
221
+ // trailing period because callers stitch them into their own sentences.
222
+ const LANDING_REASON_SENTENCES = {
223
+ accept_all_but_protected: 'the accept-everything policy covers it, so it lands unless a protected lane applies',
224
+ already_auto_accepted: 'it already landed on its own earlier, nothing left to do',
225
+ autoland_policy_missing_owner: 'the self-landing policy has no owner recorded, turn it on again to set one',
226
+ autoland_policy_off: 'self-landing is off, so everything waits for you',
227
+ certified_independent_review: 'an independent reviewer certified it, so it can land',
228
+ certified_strict_verify: 'it is certified and its recorded check passed, so it can land',
229
+ dead_exports: 'the change leaves behind code nothing calls, delete what the hygiene check names and re-certify',
230
+ declared_protected_lane: 'it touches a protected lane, so it waits for your decision',
231
+ forced_completion_needs_human: 'it was pushed to done without proof, so only a human can accept it',
232
+ insufficient_review_passes: 'it has not been reviewed enough times yet, one more pass unblocks it',
233
+ judge_equals_worker: 'built and judged by the same actor, hand the review to someone else',
234
+ mission_xp_requires_end_to_end_receipt: 'the proof does not show the whole flow working start to finish, attach that receipt or move it back to do',
235
+ needs_independent_reviewer: 'built and judged by the same actor, a review from someone else unblocks it',
236
+ needs_second_actor_review: 'the same actor built and reviewed it, someone else has to look before it lands',
237
+ needs_second_reviewer_or_third_pass: 'it needs one more independent check first',
238
+ no_verify_command: 'no check command was given, add one so the work can prove itself',
239
+ not_agent_certified: 'no reviewer has certified it yet, a second review unblocks it',
240
+ not_in_review: 'it is not up for review yet, finish the work and move it to review first',
241
+ probation_needs_review: 'the builder is still earning trust, so a human look is required before it lands',
242
+ proof_not_executed: 'the proof was written but never actually run, execute the check and record what it showed',
243
+ proof_required: 'no proof was given, say what was run and what it showed',
244
+ proof_unmerged_or_draft_pr_boundary: 'its proof points at an unmerged draft, merge it or prove the work another way',
245
+ receipt_verifier_failed: 'a saved receipt failed its re-check, fix what it names and re-certify',
246
+ strict_verify_missing: 'no recorded check command to re-run, add one and re-certify',
247
+ untagged_protected_lane_text: 'the description reads like protected-lane work without the tag, tag it or decide it yourself',
248
+ verification_pending: 'the recorded check has not been re-run yet, the next hourly pass runs it',
249
+ verifier_is_builder: 'the re-check actor built this row, another actor must re-check',
250
+ verify_command_not_allowed: 'its recorded check is not on the list this machine may run alone, run it yourself to unblock',
251
+ verify_failed: 'its check command failed on re-run, fix the failure and re-certify',
252
+ verify_passed: 'its check command passed on re-run',
253
+ verify_unrunnable: 'its check command could not even start, fix the command and re-certify',
254
+ verify_workdir_missing: 'the folder its check was recorded in is gone, re-run the check from a real folder',
255
+ verify_worktree_missing: 'the folder its check needs is gone, rebuild it or run the check by hand',
256
+ weak_proof: 'the proof is too thin to trust, cite a command, receipt, or approval',
257
+ };
258
+
259
+ const DENIED_TAG_SENTENCES = {
260
+ billing: 'it touches money, so it is your decision',
261
+ deploy: 'it is a deploy, so it is your decision',
262
+ security: 'it touches security, so it is your decision',
263
+ customer: 'it is customer-facing, so it is your decision',
264
+ external: 'it is outward-facing, so it is your decision',
265
+ feedback: 'it answers customer feedback, so it is your decision',
266
+ };
267
+
268
+ function plainLandingReason(reason) {
269
+ const code = String(reason || '').trim();
270
+ if (!code) return 'no reason was recorded, re-run the review to get one';
271
+ if (LANDING_REASON_SENTENCES[code]) return LANDING_REASON_SENTENCES[code];
272
+ const denied = code.match(/^denied_tag_(.+)$/);
273
+ if (denied) {
274
+ return DENIED_TAG_SENTENCES[denied[1]]
275
+ || `it is tagged ${denied[1].replace(/_/g, ' ')}, a protected lane, so it is your decision`;
276
+ }
277
+ const approval = code.match(/^approval_(.+)$/);
278
+ if (approval) return `a human already marked it ${approval[1].replace(/_/g, ' ')}, so it will not land on its own`;
279
+ // Unknown codes still never leak snake_case to a human.
280
+ return code.replace(/_/g, ' ');
281
+ }
282
+
157
283
  module.exports = {
158
284
  gateForHuman,
159
285
  isRetiredFillerReason,
160
286
  landingWhyClause,
161
287
  numberWord,
288
+ plainLandingReason,
162
289
  scanText,
163
290
  };
package/lib/wiki.js CHANGED
@@ -11,6 +11,13 @@ const LEGACY_WIKI_BRIEFS_SUBDIR = 'syntheses';
11
11
  const WIKI_SUBDIRS = ['people', 'systems', 'concepts', WIKI_BRIEFS_SUBDIR];
12
12
  const WIKI_STATUS_FILE = 'STATUS.md';
13
13
  const WIKI_CONTENT_SUBDIRS = WIKI_SUBDIRS.map((subdir) => path.join(WIKI_ROOT, subdir));
14
+ const WIKI_METABOLISM_MARKER = path.join('.atris', 'state', 'wiki.metabolism.json');
15
+ const DEFAULT_WIKI_METABOLISM_BUDGETS = Object.freeze({
16
+ index: 40,
17
+ log: 60,
18
+ folder: 30,
19
+ });
20
+ const CONSOLIDATION_GROWTH_THRESHOLD = 10;
14
21
 
15
22
  function getWikiRoot(mode = 'public') {
16
23
  return mode === 'private' ? PRIVATE_WIKI_ROOT : WIKI_ROOT;
@@ -577,7 +584,122 @@ function isConfidence(value) {
577
584
  return Number.isFinite(n) && n >= 0 && n <= 1;
578
585
  }
579
586
 
580
- function validateAgentReadableWikiPages(projectRoot = process.cwd(), mode = 'public') {
587
+ function countBullets(filePath, topLevelOnly = false) {
588
+ if (!fs.existsSync(filePath)) return 0;
589
+ const pattern = topLevelOnly ? /^-\s+\S/ : /^\s*-\s+\S/;
590
+ return fs.readFileSync(filePath, 'utf8')
591
+ .split('\n')
592
+ .filter((line) => pattern.test(line))
593
+ .length;
594
+ }
595
+
596
+ function countWikiMetabolismEntries(projectRoot = process.cwd(), mode = 'public') {
597
+ const wikiDir = path.join(projectRoot, getWikiRoot(mode));
598
+ const counts = {
599
+ 'index.md': countBullets(path.join(wikiDir, 'index.md')),
600
+ 'log.md': countBullets(path.join(wikiDir, 'log.md'), true),
601
+ };
602
+
603
+ for (const subdir of WIKI_SUBDIRS) {
604
+ counts[`${subdir}/`] = walkMarkdownFiles(path.join(wikiDir, subdir)).length;
605
+ }
606
+
607
+ return counts;
608
+ }
609
+
610
+ function readWikiMetabolismMarker(projectRoot = process.cwd()) {
611
+ const markerPath = path.join(projectRoot, WIKI_METABOLISM_MARKER);
612
+ if (!fs.existsSync(markerPath)) return null;
613
+
614
+ try {
615
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
616
+ if (!marker || typeof marker !== 'object' || !marker.entryCountAtConsolidation || typeof marker.entryCountAtConsolidation !== 'object') {
617
+ return null;
618
+ }
619
+ return marker;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+
625
+ function writeWikiMetabolismMarker(projectRoot = process.cwd(), entryCounts = {}, lastConsolidatedAt = new Date().toISOString()) {
626
+ const markerPath = path.join(projectRoot, WIKI_METABOLISM_MARKER);
627
+ const marker = {
628
+ lastConsolidatedAt,
629
+ entryCountAtConsolidation: { ...entryCounts },
630
+ };
631
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
632
+ fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
633
+ return marker;
634
+ }
635
+
636
+ function wikiBudgetForSurface(surface, budgets) {
637
+ if (surface === 'index.md') return Number.isFinite(budgets.index) ? budgets.index : DEFAULT_WIKI_METABOLISM_BUDGETS.index;
638
+ if (surface === 'log.md') return Number.isFinite(budgets.log) ? budgets.log : DEFAULT_WIKI_METABOLISM_BUDGETS.log;
639
+ const folder = surface.replace(/\/$/, '');
640
+ if (budgets.folders && Number.isFinite(budgets.folders[folder])) return budgets.folders[folder];
641
+ if (Number.isFinite(budgets[folder])) return budgets[folder];
642
+ return Number.isFinite(budgets.folder) ? budgets.folder : DEFAULT_WIKI_METABOLISM_BUDGETS.folder;
643
+ }
644
+
645
+ function wikiMetabolismSurfacePath(surface, mode = 'public') {
646
+ return `${getWikiRoot(mode)}/${surface}`;
647
+ }
648
+
649
+ function findWikiMetabolismFindings(projectRoot = process.cwd(), mode = 'public', budgets = DEFAULT_WIKI_METABOLISM_BUDGETS) {
650
+ const counts = countWikiMetabolismEntries(projectRoot, mode);
651
+ const marker = readWikiMetabolismMarker(projectRoot);
652
+ const findings = [];
653
+
654
+ for (const [surface, count] of Object.entries(counts)) {
655
+ const budget = wikiBudgetForSurface(surface, budgets || {});
656
+ const page = wikiMetabolismSurfacePath(surface, mode);
657
+ if (count > budget) {
658
+ findings.push({
659
+ page,
660
+ surface,
661
+ code: 'over-budget',
662
+ count,
663
+ budget,
664
+ message: `${surface} has ${count} entries, over budget ${budget}`,
665
+ });
666
+ }
667
+
668
+ const baseline = Number(marker?.entryCountAtConsolidation?.[surface]);
669
+ const growth = Number.isFinite(baseline) ? count - baseline : 0;
670
+ if (growth >= CONSOLIDATION_GROWTH_THRESHOLD) {
671
+ findings.push({
672
+ page,
673
+ surface,
674
+ code: 'consolidation-due',
675
+ count,
676
+ baselineCount: baseline,
677
+ growth,
678
+ message: `${surface} grew by ${growth} entries since the last consolidation`,
679
+ });
680
+ }
681
+ }
682
+
683
+ return findings;
684
+ }
685
+
686
+ function wikiMetabolismNudge(projectRoot = process.cwd(), mode = 'public') {
687
+ try {
688
+ const findings = findWikiMetabolismFindings(projectRoot, mode);
689
+ return findings.length > 0
690
+ ? 'the wiki has grown a lot since its last pruning; run: atris wiki consolidate'
691
+ : '';
692
+ } catch {
693
+ return '';
694
+ }
695
+ }
696
+
697
+ function recordWikiConsolidation(projectRoot = process.cwd(), mode = 'public') {
698
+ const counts = countWikiMetabolismEntries(projectRoot, mode);
699
+ return writeWikiMetabolismMarker(projectRoot, counts);
700
+ }
701
+
702
+ function validateAgentReadableWikiPages(projectRoot = process.cwd(), mode = 'public', options = {}) {
581
703
  const findings = [];
582
704
  const pages = readWikiPages(projectRoot, mode);
583
705
  const stalePages = new Set(findStaleWikiPages(projectRoot, mode).map((item) => item.page));
@@ -609,9 +731,12 @@ function validateAgentReadableWikiPages(projectRoot = process.cwd(), mode = 'pub
609
731
  }
610
732
  }
611
733
 
734
+ findings.push(...findWikiMetabolismFindings(projectRoot, mode, options.budgets));
735
+
612
736
  return {
613
737
  ok: findings.length === 0,
614
738
  pageCount: pages.length,
739
+ surfaceCounts: countWikiMetabolismEntries(projectRoot, mode),
615
740
  findingCount: findings.length,
616
741
  findings,
617
742
  };
@@ -802,18 +927,29 @@ Output:
802
927
  - No hedging if the wiki is stale or messy`;
803
928
  }
804
929
 
930
+ function buildConsolidatePrompt(mode = 'public') {
931
+ const wikiRoot = getWikiRoot(mode);
932
+ return `Atris wiki consolidation pass
933
+
934
+ Read ${wikiRoot}/index.md, ${wikiRoot}/log.md, and every page under the wiki subfolders.
935
+
936
+ Pruning rules:
937
+ - UPDATE facts that changed.
938
+ - DELETE stale facts, duplicates, and mechanics.
939
+ - ADD nothing new.
940
+
941
+ Keep rules:
942
+ - Entries marked as user-stated conventions or explicit remember-this survive.
943
+ - Every kept entry keeps its provenance as a source link or date.
944
+
945
+ Consolidate overlapping entries, preserve the strongest source-backed version, and leave index.md and page links consistent.`;
946
+ }
947
+
805
948
  module.exports = {
806
949
  WIKI_ROOT,
807
- CONTEXT_ROOT,
808
950
  PRIVATE_WIKI_ROOT,
809
- PRIVATE_CONTEXT_ROOT,
810
- LEGACY_WIKI_ROOT,
811
- WIKI_SUBDIRS,
812
- WIKI_CONTENT_SUBDIRS,
813
- WIKI_SCHEMA: buildWikiSchema(),
814
- WIKI_STATUS_FILE,
951
+ WIKI_METABOLISM_MARKER,
815
952
  getWikiRoot,
816
- getContextRoot,
817
953
  ensureWikiScaffold,
818
954
  ensureContextScaffold,
819
955
  findLocalWikiDir,
@@ -822,6 +958,12 @@ module.exports = {
822
958
  readWikiPages,
823
959
  findStaleWikiPages,
824
960
  findWikiOrphans,
961
+ countWikiMetabolismEntries,
962
+ readWikiMetabolismMarker,
963
+ writeWikiMetabolismMarker,
964
+ findWikiMetabolismFindings,
965
+ wikiMetabolismNudge,
966
+ recordWikiConsolidation,
825
967
  validateAgentReadableWikiPages,
826
968
  findSuggestedSources,
827
969
  stageWikiIngest,
@@ -830,4 +972,5 @@ module.exports = {
830
972
  buildIngestPrompt,
831
973
  buildQueryPrompt,
832
974
  buildLintPrompt,
975
+ buildConsolidatePrompt,
833
976
  };
package/lib/wish-audit.js CHANGED
@@ -167,7 +167,7 @@ const BUDGET_LABELS = {
167
167
  const WISH_SWEEP_LIMIT = 3;
168
168
  const WISH_MISSION_RUNNER = 'claude';
169
169
  const TEST_VERIFY_COMMAND = 'node --test';
170
- const JOURNAL_RESULT_TEXT = 'a written result will be waiting in your journal';
170
+ const JOURNAL_RESULT_TEXT = 'the wish is recorded and the result comes back for review';
171
171
  const WAITING_INPUT_STATUSES = new Set(['needs_input', 'waiting_input']);
172
172
  const PATH_SLOT_QUESTION = 'Where should I find the file or folder you named?';
173
173
  const AUDIENCE_WORDS = /\b(?:for|user|users|customer|customers|operator|operators|agent|agents|developer|developers|admin|admins|member|members|team|teams|visitor|visitors|client|clients|student|students|founder|founders|newcomer|newcomers)\b/i;
@@ -490,6 +490,24 @@ function resolveRole(role, root) {
490
490
  return resolver(role, root);
491
491
  }
492
492
 
493
+ // Route-time validator policy: the routing decision must not depend on which
494
+ // validator binaries happen to exist on this machine. Prefer a ready distinct
495
+ // validator, else fall back to the registry's policy order; the dispatch
496
+ // flight reports an honest validator failure if the engine is still missing
497
+ // when validation actually runs.
498
+ function policyValidator(root, excludeId = '') {
499
+ try {
500
+ const ranked = engineRegistry.engineRegistryView(root)
501
+ .filter((engine) => engine.roles.includes('validator'))
502
+ .filter((engine) => engine.id !== String(excludeId || ''))
503
+ .sort((a, b) => Number(a.fallback_order) - Number(b.fallback_order)
504
+ || String(a.id).localeCompare(String(b.id)));
505
+ return ranked.find((engine) => engine.health && engine.health.status === 'ready') || ranked[0] || null;
506
+ } catch {
507
+ return null;
508
+ }
509
+ }
510
+
493
511
  function resolveExecutor(root, engineOverride = '') {
494
512
  if (engineOverride) {
495
513
  return engineRegistry.resolveEngineForRoleWithPreference('executor', root, engineOverride);
@@ -1239,7 +1257,8 @@ function auditWish(text, root = process.cwd(), options = {}) {
1239
1257
  const question = 'Which working reviewer should validate it?';
1240
1258
  const answered = answeredRepeatedQuestionValue(text, root, question, 'reviewer');
1241
1259
  if (answered) validator = { id: slugify(answered), name: answered };
1242
- else questions.push(question);
1260
+ else if (options.assumeValidator) validator = policyValidator(root, executor && executor.id);
1261
+ if (!validator) questions.push(question);
1243
1262
  }
1244
1263
 
1245
1264
  let missing = missingNamedInputs(originalText, root);
@@ -1346,9 +1365,6 @@ function verifyOutcomeText(planOrCommand) {
1346
1365
  }
1347
1366
 
1348
1367
  module.exports = {
1349
- BUDGET_LABELS,
1350
- JOURNAL_RESULT_TEXT,
1351
- TEST_VERIFY_COMMAND,
1352
1368
  WAITING_INPUT_STATUSES,
1353
1369
  WISH_MISSION_RUNNER,
1354
1370
  WISH_SWEEP_LIMIT,
@@ -1357,8 +1373,6 @@ module.exports = {
1357
1373
  deriveVerifyPlan,
1358
1374
  inferBudgetTier,
1359
1375
  missingNamedInputs,
1360
- namedTestFiles,
1361
- PATH_SLOT_QUESTION,
1362
1376
  quoteText,
1363
1377
  sharesMeaningfulWords,
1364
1378
  validateEngineOverride,