atris 3.37.0 → 3.38.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 (139) hide show
  1. package/README.md +2 -0
  2. package/atris/PERSONA.md +3 -7
  3. package/atris.md +10 -2
  4. package/ax +158 -33
  5. package/bin/atris.js +49 -2
  6. package/commands/activate.js +16 -3
  7. package/commands/agent-spawn.js +0 -7
  8. package/commands/autoland.js +92 -14
  9. package/commands/autopilot-front.js +0 -4
  10. package/commands/autopilot.js +0 -12
  11. package/commands/brain.js +0 -7
  12. package/commands/brief.js +0 -2
  13. package/commands/business-sync.js +0 -1
  14. package/commands/business.js +107 -12
  15. package/commands/clean.js +31 -2
  16. package/commands/close.js +46 -11
  17. package/commands/cloud.js +0 -1
  18. package/commands/codex-goal.js +0 -2
  19. package/commands/computer.js +6 -5
  20. package/commands/console.js +46 -9
  21. package/commands/deck.js +29 -8
  22. package/commands/dream.js +0 -7
  23. package/commands/drill.js +0 -1
  24. package/commands/engine.js +10 -18
  25. package/commands/experiments.js +0 -4
  26. package/commands/fleet-report.js +12 -4
  27. package/commands/game.js +261 -0
  28. package/commands/gm.js +10 -2
  29. package/commands/goal.js +0 -6
  30. package/commands/harvest.js +0 -1
  31. package/commands/improve.js +29 -8
  32. package/commands/init.js +18 -34
  33. package/commands/integrations.js +0 -7
  34. package/commands/land.js +71 -8
  35. package/commands/launchpad.js +0 -2
  36. package/commands/lesson.js +17 -7
  37. package/commands/loop-front.js +0 -2
  38. package/commands/loops.js +6 -5
  39. package/commands/meet.js +0 -2
  40. package/commands/member.js +68 -3
  41. package/commands/mission.js +359 -102
  42. package/commands/next.js +0 -1
  43. package/commands/now.js +0 -12
  44. package/commands/one-lap.js +8 -8
  45. package/commands/orb.js +0 -4
  46. package/commands/pack-craft.js +0 -1
  47. package/commands/pack.js +670 -77
  48. package/commands/play.js +0 -2
  49. package/commands/probe.js +10 -4
  50. package/commands/pulse.js +73 -48
  51. package/commands/push.js +0 -4
  52. package/commands/radar.js +14 -11
  53. package/commands/recover.js +340 -0
  54. package/commands/report.js +0 -1
  55. package/commands/router.js +319 -0
  56. package/commands/run-front.js +0 -2
  57. package/commands/search.js +0 -3
  58. package/commands/skill.js +7 -2
  59. package/commands/slop.js +225 -7
  60. package/commands/status.js +6 -0
  61. package/commands/stream.js +0 -2
  62. package/commands/study.js +0 -3
  63. package/commands/sync-checkout.js +51 -0
  64. package/commands/sync.js +11 -40
  65. package/commands/task.js +125 -39
  66. package/commands/teach.js +86 -0
  67. package/commands/unknowns.js +0 -10
  68. package/commands/update.js +0 -1
  69. package/commands/visualize.js +0 -5
  70. package/commands/watch.js +0 -6
  71. package/commands/wiki.js +41 -11
  72. package/commands/wish.js +0 -1
  73. package/commands/worktree.js +87 -14
  74. package/commands/xp.js +1 -6
  75. package/commands/youtube.js +0 -2
  76. package/lib/accept-verify-gate.js +122 -0
  77. package/lib/activity-stream.js +0 -2
  78. package/lib/auto-accept-certified.js +145 -2
  79. package/lib/autoland.js +0 -3
  80. package/lib/ax-auto-lane.js +94 -10
  81. package/lib/bench/context.js +0 -2
  82. package/lib/bench/report.js +0 -1
  83. package/lib/bench/runner.js +0 -11
  84. package/lib/brief-ledger.js +0 -6
  85. package/lib/business-simulate.js +382 -0
  86. package/lib/chat-log-scan.js +0 -5
  87. package/lib/checkout-sync.js +52 -0
  88. package/lib/claude-boot-block.js +48 -0
  89. package/lib/cloud-mission.js +0 -6
  90. package/lib/codex-flight.js +0 -1
  91. package/lib/conductor-artifacts.js +25 -0
  92. package/lib/context-gatherer.js +0 -1
  93. package/lib/deck-compose.js +0 -1
  94. package/lib/deck-history.js +0 -1
  95. package/lib/deck-layout.js +0 -5
  96. package/lib/deck-review.js +0 -7
  97. package/lib/deck-schema.js +0 -1
  98. package/lib/default-verifier.js +0 -2
  99. package/lib/engine-registry.js +16 -8
  100. package/lib/experiments/daily.js +0 -6
  101. package/lib/fleet.js +210 -27
  102. package/lib/functional-owner.js +0 -3
  103. package/lib/inspect-fields.js +0 -1
  104. package/lib/known-commands.js +2 -2
  105. package/lib/learnings.js +0 -2
  106. package/lib/loop-doctor.js +11 -17
  107. package/lib/manifest.js +0 -2
  108. package/lib/member-alive.js +0 -1
  109. package/lib/member-switches.js +0 -5
  110. package/lib/mission-artifact.js +0 -1
  111. package/lib/mission-protected-lane.js +293 -0
  112. package/lib/mission-room.js +0 -2
  113. package/lib/mission-runtime-loop.js +0 -1
  114. package/lib/next-moves.js +0 -18
  115. package/lib/official-cli-integration.js +0 -4
  116. package/lib/orb-context.js +6 -3
  117. package/lib/orb-scorecard.js +0 -1
  118. package/lib/outbound-send-gate.js +0 -6
  119. package/lib/permission-grants.js +0 -6
  120. package/lib/policy-lessons.js +0 -2
  121. package/lib/pulse.js +68 -15
  122. package/lib/review-integrity.js +0 -2
  123. package/lib/router-brain.js +0 -2
  124. package/lib/runner-command.js +0 -4
  125. package/lib/runtime-bootstrap.js +0 -1
  126. package/lib/scorecard.js +0 -1
  127. package/lib/self-drive.js +1 -1
  128. package/lib/state-detection.js +0 -6
  129. package/lib/task-db.js +0 -4
  130. package/lib/team-presence.js +0 -1
  131. package/lib/voice-gate.js +63 -2
  132. package/lib/wiki.js +152 -9
  133. package/lib/wish-audit.js +20 -6
  134. package/lib/wish-delegate.js +26 -20
  135. package/lib/wish-stats.js +0 -5
  136. package/lib/wish-store.js +0 -1
  137. package/lib/workspace-safety.js +0 -2
  138. package/lib/zip.js +0 -1
  139. package/package.json +1 -1
package/lib/pulse.js CHANGED
@@ -288,8 +288,16 @@ function shouldFallbackToAutopilot({ missionReason, autopilotFallback = true, no
288
288
  // actual delta (new commit or newly-dirtied files), never the whole dirty tree —
289
289
  // crediting pre-existing dirt re-rewards the same change every tick (the reward
290
290
  // inflation bug). The caller computes producedWork from a before/after snapshot.
291
- function scoreTick({ verifyPassed, producedWork } = {}) {
291
+ //
292
+ // A crashed engine must also score -1. Before this, a hard `mission error` tick
293
+ // produced no work and ran no verifier, so it scored 0 — indistinguishable from
294
+ // a healthy idle tick. The 2026-07-16 heartbeat death proved the cost: 25
295
+ // consecutive error ticks left reward_sum flat, nothing escalated, and the loop
296
+ // stopped with a ledger that still read green. `actorReason: 'error'` is a
297
+ // failure, not a rest.
298
+ function scoreTick({ verifyPassed, producedWork, actorOk, actorReason } = {}) {
292
299
  if (verifyPassed === false) return -1;
300
+ if (actorOk === false && actorReason === 'error') return -1;
293
301
  if (!producedWork) return 0;
294
302
  return verifyPassed === true ? 1 : 0;
295
303
  }
@@ -575,7 +583,7 @@ function releaseLock(root) {
575
583
  } catch {}
576
584
  }
577
585
 
578
- // --- cron tick script + crontab line (pure string generation) ---
586
+ // --- scheduled tick script + launchd plist (pure string generation) ---
579
587
 
580
588
  // Minimal shell wrapper modeled on the proven commander tick.sh: deadline
581
589
  // self-removal, then hand off to `atris pulse tick` (all real logic lives in JS,
@@ -593,6 +601,8 @@ function buildTickScript(opts = {}) {
593
601
  runnerCommandTemplate = '',
594
602
  verifyCmd = 'npm test',
595
603
  pathDirs = [],
604
+ launchLabel = '',
605
+ launchPlistPath = '',
596
606
  } = opts;
597
607
  if (!root) throw new Error('buildTickScript: root is required');
598
608
  if (!stateHome) throw new Error('buildTickScript: stateHome is required');
@@ -636,16 +646,26 @@ STATE="${stateHome}"
636
646
  LOG_DIR="$STATE/logs"
637
647
  DEADLINE_EPOCH="${deadlineEpoch}"
638
648
  MARKER="${marker}"
649
+ LAUNCH_LABEL="${launchLabel}"
650
+ LAUNCH_PLIST="${launchPlistPath}"
651
+
652
+ # Claude Max subscription auth comes from the macOS keychain. An API key would
653
+ # override it and route autonomous work onto metered API billing.
654
+ unset ANTHROPIC_API_KEY
639
655
 
640
- # Cron has a minimal PATH; restore the dirs the engine's bare-name spawns need.
656
+ # launchd has a minimal PATH; restore the dirs the engine's bare-name spawns need.
641
657
  ${pathExport}
642
658
 
643
659
  mkdir -p "$LOG_DIR"
644
660
 
645
661
  now="$(date +%s)"
646
662
  if [ "$now" -ge "$DEADLINE_EPOCH" ]; then
663
+ if [ -n "$LAUNCH_LABEL" ]; then
664
+ launchctl bootout "gui/$(id -u)/$LAUNCH_LABEL" 2>/dev/null || true
665
+ [ -n "$LAUNCH_PLIST" ] && rm -f "$LAUNCH_PLIST"
666
+ fi
647
667
  crontab -l 2>/dev/null | grep -v "$MARKER" | crontab - 2>/dev/null || true
648
- echo "$(date -Iseconds) pulse expired; removed cron" >> "$LOG_DIR/control.log"
668
+ echo "$(date -Iseconds) pulse expired; removed scheduler" >> "$LOG_DIR/control.log"
649
669
  expired_at="$(date -Iseconds)"
650
670
  journal_year="$(date +%Y)"
651
671
  journal_day="$(date +%F)"
@@ -693,6 +713,48 @@ fi
693
713
  `;
694
714
  }
695
715
 
716
+ function xmlEscape(value) {
717
+ return String(value)
718
+ .replace(/&/g, '&')
719
+ .replace(/</g, '&lt;')
720
+ .replace(/>/g, '&gt;')
721
+ .replace(/"/g, '&quot;')
722
+ .replace(/'/g, '&apos;');
723
+ }
724
+
725
+ function launchIntervalFromCadence({ cadenceMinutes, cron } = {}) {
726
+ const minutes = Number(cadenceMinutes);
727
+ if (Number.isInteger(minutes) && minutes > 0) return { seconds: minutes * 60, fallback: false };
728
+ const normalized = normalizeCronCadence(cron || DEFAULT_CADENCE_CRON);
729
+ const everyMinutes = normalized.match(/^\*\/(\d+) \* \* \* \*$/);
730
+ if (everyMinutes) return { seconds: Number(everyMinutes[1]) * 60, fallback: false };
731
+ if (/^\d+ \* \* \* \*$/.test(normalized)) return { seconds: 3600, fallback: false };
732
+ return { seconds: 3600, fallback: true };
733
+ }
734
+
735
+ function buildLaunchAgentPlist(opts = {}) {
736
+ const { label, scriptPath, stateHome } = opts;
737
+ if (!label) throw new Error('buildLaunchAgentPlist: label is required');
738
+ if (!scriptPath) throw new Error('buildLaunchAgentPlist: scriptPath is required');
739
+ if (!stateHome) throw new Error('buildLaunchAgentPlist: stateHome is required');
740
+ const interval = launchIntervalFromCadence(opts).seconds;
741
+ const logPath = path.join(stateHome, 'logs', 'launchd.log');
742
+ return `<?xml version="1.0" encoding="UTF-8"?>
743
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
744
+ <plist version="1.0">
745
+ <dict>
746
+ <key>Label</key><string>${xmlEscape(label)}</string>
747
+ <key>ProgramArguments</key>
748
+ <array><string>/bin/bash</string><string>${xmlEscape(scriptPath)}</string></array>
749
+ <key>StartInterval</key><integer>${interval}</integer>
750
+ <key>AbandonProcessGroup</key><true/>
751
+ <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
752
+ <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
753
+ </dict>
754
+ </plist>
755
+ `;
756
+ }
757
+
696
758
  function buildCrontabLine(opts = {}) {
697
759
  const { cron = DEFAULT_CADENCE_CRON, scriptPath, marker = PULSE_MARKER } = opts;
698
760
  if (!scriptPath) throw new Error('buildCrontabLine: scriptPath is required');
@@ -703,28 +765,17 @@ module.exports = {
703
765
  PULSE_RECEIPT_SCHEMA,
704
766
  SCORECARD_SCHEMA,
705
767
  PULSE_MARKER,
706
- LEGACY_STATE_DIRNAME,
707
768
  DEFAULT_CADENCE_CRON,
708
769
  STALE_TICK_MS,
709
- LIVENESS_STALE_MS,
710
770
  stateDir,
711
- pulseRepoSlug,
712
- pulseStateHome,
713
- pulseMarker,
714
771
  legacyPulseStateHome,
715
- readTickScriptRoot,
716
- legacyPulseStateMatchesRoot,
717
772
  resolvePulseSlot,
718
773
  pulseReceiptsPath,
719
774
  scorecardsPath,
720
- pulseCounterPath,
721
- pulseLockDir,
722
775
  buildPulseReceipt,
723
776
  buildPulseScorecardRow,
724
- buildOrbFailureScorecardRow,
725
777
  buildInterruptedPulseReceipt,
726
778
  ingestOrbScorecard,
727
- orbRunKey,
728
779
  scoreTick,
729
780
  fsSnapshot,
730
781
  diffFsSnapshots,
@@ -744,6 +795,8 @@ module.exports = {
744
795
  acquireLock,
745
796
  releaseLock,
746
797
  buildTickScript,
798
+ buildLaunchAgentPlist,
799
+ launchIntervalFromCadence,
747
800
  buildCrontabLine,
748
801
  normalizeCronCadence,
749
802
  };
@@ -133,9 +133,7 @@ function validateActor(actor, { root } = {}) {
133
133
  }
134
134
 
135
135
  module.exports = {
136
- RESERVED_ACTORS,
137
136
  normalizeActor,
138
- envActor,
139
137
  taskBuilder,
140
138
  reviewEventActors,
141
139
  independentReviewActors,
@@ -342,8 +342,6 @@ function routerPickExplanation(decision) {
342
342
  }
343
343
 
344
344
  module.exports = {
345
- DEFAULT_MIN_RECEIPTS,
346
- DEFAULT_HALF_LIFE_MS,
347
345
  loadRouterHistory,
348
346
  computeEngineTaskStats,
349
347
  rankEngines,
@@ -230,11 +230,7 @@ module.exports = {
230
230
  RUNNER_PROFILE_DEFS,
231
231
  RUNNER_PROFILE_ALIASES,
232
232
  RUNNER_PROFILE_NAMES,
233
- resolveRunnerProfileName,
234
233
  resolveRunnerProfile,
235
- resolveRunnerModel: resolveClaudeRunnerModel,
236
- resolveRunnerBin: resolveClaudeRunnerBin,
237
- resolveRunnerCommandTemplate: resolveClaudeRunnerCommandTemplate,
238
234
  resolveClaudeRunnerModel,
239
235
  resolveClaudeRunnerBin,
240
236
  resolveClaudeRunnerCommandTemplate,
@@ -114,6 +114,5 @@ function buildRemoteAtrisBootstrapCommand(options = {}) {
114
114
 
115
115
  module.exports = {
116
116
  buildRemoteAtrisBootstrapCommand,
117
- getAtrisPackageVersion,
118
117
  writeRuntimeReceipt,
119
118
  };
package/lib/scorecard.js CHANGED
@@ -331,7 +331,6 @@ function readScorecards(atrisDir) {
331
331
  }
332
332
 
333
333
  module.exports = {
334
- PRIVATE_MEMORY_ROOT,
335
334
  getScorecardsPath,
336
335
  buildScorecardData,
337
336
  writeScorecard,
package/lib/self-drive.js CHANGED
@@ -255,4 +255,4 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
255
255
  }
256
256
  }
257
257
 
258
- module.exports = { handleMissionBlocker };
258
+ module.exports = { handleMissionBlocker, reasonClass, HUMAN_BLOCKING_REASONS };
@@ -406,11 +406,5 @@ function loadContext(workspaceDir) {
406
406
  module.exports = {
407
407
  detectWorkspaceState,
408
408
  loadContext,
409
- getTodayInboxItems,
410
- getTodayLogPath,
411
- getBacklogTasks,
412
- getInProgressTasks,
413
- getCompletedTasks,
414
- getTaskCounts,
415
409
  getTaskGlance
416
410
  };
package/lib/task-db.js CHANGED
@@ -2051,7 +2051,6 @@ module.exports = {
2051
2051
  doneTask,
2052
2052
  archiveTask,
2053
2053
  relabelArchivedTasks,
2054
- isJune10BacklogResetMarker,
2055
2054
  readyTask,
2056
2055
  setTaskResult,
2057
2056
  reviseTask,
@@ -2065,10 +2064,7 @@ module.exports = {
2065
2064
  taskProjection,
2066
2065
  renderTodoMarkdown,
2067
2066
  normalizeTaskRef,
2068
- shortestUniqueTaskRef,
2069
2067
  taskDisplayRefMap,
2070
2068
  withTaskDisplayRefs,
2071
2069
  newId,
2072
- // Test surface
2073
- _SCHEMA: SCHEMA,
2074
2070
  };
@@ -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
  };
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
 
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
@@ -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,