diff --git a/docs/architecture/doctor-health-modules.md b/docs/architecture/doctor-health-modules.md index f35f0e87..20c33dd1 100644 --- a/docs/architecture/doctor-health-modules.md +++ b/docs/architecture/doctor-health-modules.md @@ -81,8 +81,10 @@ they must not appear in routine Doctor output or public JSON. Every `jarvos doctor` profile also projects its core checks and selected optional components into `systemDoctor`, a backward-compatible -`jarvos-system-doctor-report/v1` receipt. The text view preserves its existing -transcript and appends the receipt's selected components and final result. +`jarvos-system-doctor-report/v1` receipt. The text view is a compact scoreboard: +one icon and one line per component, with short section labels only when the +receipt contains more than one section. Degraded rows include a concise reason +and next action; the structured reason code remains available in JSON. Existing top-level JSON fields remain available to older consumers. A clean profile has no selected optional components unless an owner-side @@ -101,7 +103,7 @@ of `healthy`, `warning`, `repair needed`, or `not configured`. "observedAt": "2026-09-03T18:00:00.000Z", "validUntil": "2026-09-04T18:00:00.000Z", "trust": "trusted", - "factsVersion": "jarvos-system-doctor-facts/v1", + "factsVersion": "jarvos-system-doctor-facts/v2", "facts": { "profile": "minimal", "components": [ @@ -126,12 +128,17 @@ the selected runtime's local-search tool. If the service responds while a real search returns zero results or the runtime tool is absent, jarvOS downgrades the component to `warning`. -When the system snapshot selects Memory, it must contain the existing ten -components in their fixed order: GBrain, Lossless Claw, QMD search, +When the system snapshot selects Memory, it must contain the existing eleven +components in their fixed order: GBrain core, GBrain semantic coverage, +Lossless Claw, QMD search, memory-wiki, Notes & provenance, Recall evaluation, Scheduled maintenance, Reviewed runtime, Automatic repair, and Telegram follow-up & proof. Partial or -reordered Memory rosters fail closed. The legacy aggregate `memory.json` -contract remains supported. +reordered Memory rosters fail closed. The eleven-row roster is versioned as +`jarvos-system-doctor-facts/v2`; v1 snapshots fail closed rather than being +mistaken for complete coverage. The legacy aggregate `memory.json` contract +remains supported when no v2 Memory roster is present. Once that roster is +present, it is authoritative; the aggregate is neither rendered nor part of +the eleven-component result. ## Consumer and ownership boundary @@ -147,7 +154,7 @@ The private owner remains responsible for machine-specific probes, credentials, Telegram identities, launchd bindings, repair procedures, scheduling, and publishing the normalized snapshot. The existing Memory Doctor Telegram message remains unchanged until the new projection is behaviorally proven to -retain all ten rows and at least the same operator information. +retain all eleven rows and at least the same operator information. Documentation impact: module-docs. This producer contract is a public jarvOS release candidate; private command declarations, scheduling, runtime diff --git a/lib/jarvos-cli.js b/lib/jarvos-cli.js index e239b934..a2776478 100644 --- a/lib/jarvos-cli.js +++ b/lib/jarvos-cli.js @@ -956,7 +956,11 @@ function runDoctor(options = {}) { profile: profile.id, }).modules; const continuityRequired = config?.gbrainContinuity?.required === true; - const moduleBlocking = modules.some((module) => healthModuleBlocksDoctor(module, { continuityRequired })); + const memoryProjected = modules.some((module) => module.id === 'system' + && Array.isArray(module.components) + && module.components.some((component) => component.id.startsWith('memory.'))); + const moduleBlocking = modules.some((module) => !(module.id === 'memory' && memoryProjected) + && healthModuleBlocksDoctor(module, { continuityRequired })); return { ok: results.every((item) => item.ok) && !moduleBlocking, @@ -1378,7 +1382,7 @@ async function runCli(argv = process.argv.slice(2), env = process.env, invokedAs if (parsed.options.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } else { - process.stdout.write(`${renderSystemDoctor(report, { legacyText: profileDoctor.formatDoctorResult(report) })}\n`); + process.stdout.write(`${renderSystemDoctor(report)}\n`); } return report.ok ? 0 : 1; } @@ -1386,7 +1390,7 @@ async function runCli(argv = process.argv.slice(2), env = process.env, invokedAs if (parsed.options.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } else { - process.stdout.write(`${renderSystemDoctor(report, { legacyText: renderDoctor(report) })}\n`); + process.stdout.write(`${renderSystemDoctor(report)}\n`); } return report.ok ? 0 : 1; } catch (error) { diff --git a/lib/jarvos-doctor-modules.js b/lib/jarvos-doctor-modules.js index f0a17dbe..bbc64436 100644 --- a/lib/jarvos-doctor-modules.js +++ b/lib/jarvos-doctor-modules.js @@ -16,10 +16,11 @@ const PUBLIC_STATES = Object.freeze(['healthy', 'update available', 'repair need const SNAPSHOT_FIELDS = Object.freeze(['schema', 'moduleId', 'generation', 'observedAt', 'validUntil', 'trust', 'repairable', 'updateAvailable']); const CONTINUITY_SNAPSHOT_FIELDS = Object.freeze(['schema', 'moduleId', 'generation', 'observedAt', 'validUntil', 'trust', 'factsVersion', 'facts']); const CONTINUITY_FACTS_VERSION = 'jarvos-gbrain-continuity-facts/v1'; -const SYSTEM_FACTS_VERSION = 'jarvos-system-doctor-facts/v1'; +const SYSTEM_FACTS_VERSION = 'jarvos-system-doctor-facts/v2'; const SYSTEM_COMPONENT_STATES = Object.freeze(['healthy', 'warning', 'repair needed', 'not configured']); const MEMORY_COMPONENTS = Object.freeze([ - ['memory.gbrain', 'GBrain'], + ['memory.gbrain', 'GBrain core'], + ['memory.gbrain-semantic-coverage', 'GBrain semantic coverage'], ['memory.lossless-claw', 'Lossless Claw'], ['memory.qmd-search', 'QMD search'], ['memory.memory-wiki', 'memory-wiki'], diff --git a/lib/jarvos-system-doctor.js b/lib/jarvos-system-doctor.js index ee7573d2..861fade5 100644 --- a/lib/jarvos-system-doctor.js +++ b/lib/jarvos-system-doctor.js @@ -22,33 +22,48 @@ function coreComponents(report) { })); } -function moduleState(state) { - if (state === 'repair needed') return 'repair needed'; +function moduleState(state, reasonClass) { + if (state === 'repair needed' || reasonClass === 'module-invalid') return 'repair needed'; if (state === 'healthy') return 'healthy'; return 'warning'; } function optionalComponents(modules = []) { const system = modules.find((module) => module.id === 'system'); - if (!system) return []; - if (!Array.isArray(system.components)) { - return [{ + const components = []; + if (system && !Array.isArray(system.components)) { + components.push({ id: 'module.system', label: 'System health receipt', section: 'optional', - state: moduleState(system.state), + state: moduleState(system.state, system.reasonClass), reasonClass: system.reasonClass, message: null, - }]; + }); + } else if (system) { + components.push(...system.components.map((component) => ({ + id: component.id, + label: component.label, + section: component.id.startsWith('memory.') ? 'memory' : 'optional', + state: component.state, + reasonClass: component.reasonClass, + message: null, + }))); } - return system.components.map((component) => ({ - id: component.id, - label: component.label, - section: component.id.startsWith('memory.') ? 'memory' : 'optional', - state: component.state, - reasonClass: component.reasonClass, - message: null, - })); + const memoryProjected = components.some((component) => component.section === 'memory'); + for (const module of modules) { + if (module.id === 'system' || (module.id === 'memory' && memoryProjected)) continue; + if (!['repair needed', 'needs your attention'].includes(module.state)) continue; + components.push({ + id: `module.${module.id}`, + label: module.id === 'memory' ? 'Memory receipt' : 'GBrain continuity', + section: module.id === 'memory' ? 'memory' : 'optional', + state: moduleState(module.state, module.reasonClass), + reasonClass: module.reasonClass, + message: null, + }); + } + return components; } function buildSystemDoctorReceipt(report) { @@ -75,38 +90,62 @@ function attachSystemDoctorReceipt(report) { return { ...report, systemDoctor: buildSystemDoctorReceipt(report) }; } -function renderSystemDoctor(report, { legacyText = null } = {}) { +function sentence(value) { + if (!value) return ''; + return /[.!?]$/.test(value) ? value : `${value}.`; +} + +function humanizeReason(reasonClass) { + return String(reasonClass || 'unverified').replace(/[.-]+/g, ' '); +} + +function componentExplanation(component) { + const known = { + 'http-unreachable': 'HTTP check failed. Restore access, then rerun Doctor.', + 'search-empty': 'No search results. Run a real search, then rerun Doctor.', + 'runtime-tool-missing': 'Runtime search tool unavailable. Enable it, then rerun Doctor.', + 'profile-mismatch': 'Receipt is for another profile. Publish a matching receipt.', + 'module-invalid': 'Receipt is invalid. Republish it.', + 'module-stale': 'Receipt is stale. Refresh it.', + 'module-untrusted': 'Receipt is untrusted. Publish a trusted receipt.', + }; + if (known[component.reasonClass]) return known[component.reasonClass]; + if (component.state === 'not configured') { + const detail = component.message ? sentence(component.message) : 'Not configured.'; + return `${detail} Configure it when needed.`; + } + const detail = sentence(component.message || humanizeReason(component.reasonClass)); + const action = component.state === 'repair needed' + ? 'Fix it, then rerun Doctor.' + : 'Verify it, then rerun Doctor.'; + return `${detail} ${action}`; +} + +function renderSystemDoctor(report) { const receipt = report.systemDoctor || buildSystemDoctorReceipt(report); - const lines = legacyText === null - ? [`jarvOS System Doctor — ${receipt.profile.title}`, `Workspace: ${receipt.workspace}`, '', 'Core:'] - : [legacyText.trimEnd()]; + const lines = [`jarvOS System Doctor — ${receipt.profile.title}`, `Workspace: ${receipt.workspace}`]; const marker = { healthy: '✅', warning: '⚠️', 'repair needed': '❌', - 'not configured': '◻️', + 'not configured': '⚠️', }; - const word = { - healthy: 'PASS', - warning: 'WARN', - 'repair needed': 'FAIL', - 'not configured': 'SKIP', + const sectionLabel = { + core: 'Core', + optional: 'Services', + memory: 'Memory', }; - if (legacyText === null) { - for (const component of receipt.components) { - if (component.section !== 'core') continue; - lines.push(`${marker[component.state]} ${word[component.state]} ${component.label} — ${component.state}${component.message ? ` (${component.message})` : ''}`); - } - } - const selected = receipt.components.filter((component) => component.section !== 'core'); - if (selected.length) { - lines.push('', 'Selected optional components:'); - for (const component of selected) { - lines.push(`${marker[component.state]} ${word[component.state]} ${component.label} — ${component.state}`); + const sections = ['core', 'optional', 'memory'] + .filter((section) => receipt.components.some((component) => component.section === section)); + const showSections = sections.length > 1; + for (const section of sections) { + lines.push(''); + if (showSections) lines.push(sectionLabel[section]); + for (const component of receipt.components.filter((item) => item.section === section)) { + const explanation = component.state === 'healthy' ? '' : ` — ${componentExplanation(component)}`; + lines.push(`${marker[component.state]} ${component.label}${explanation}`); } } - const prefix = legacyText === null ? '' : 'System Doctor: '; - lines.push('', receipt.status === 'healthy' ? `${prefix}READY` : `${prefix}NOT READY — ${receipt.status}`); return lines.join('\n'); } diff --git a/modules/jarvos/README.md b/modules/jarvos/README.md index 33f9476b..24e46943 100644 --- a/modules/jarvos/README.md +++ b/modules/jarvos/README.md @@ -124,8 +124,9 @@ The minimal profile checks the reusable JarvOS contract: Failure output names the exact component, such as `agent.context` or `path.vault`, so an installer or assistant can repair the missing piece directly. -JSON includes the `jarvos-system-doctor-report/v1` projection, while text keeps -its existing transcript and appends the same selected-component result. +JSON includes the `jarvos-system-doctor-report/v1` projection, while text shows +one icon and one line per component, adding a concise reason and next action only +when a component is degraded. Optional providers are absent unless selected by a profile-bound, data-only health snapshot; selected components remain individually visible as healthy, warning, repair needed, or not configured. diff --git a/tests/cli-smoke-test.js b/tests/cli-smoke-test.js index 37c41586..cb799e20 100644 --- a/tests/cli-smoke-test.js +++ b/tests/cli-smoke-test.js @@ -301,9 +301,9 @@ try { 'doctor', '--profile', 'minimal', '--workspace', attachWorkspace, ], { env: attachEnv }); assert.equal(attachDoctor.status, 0, attachDoctor.stderr || attachDoctor.stdout); - assert.match(attachDoctor.stdout, /PASS workspace-files/); - assert.match(attachDoctor.stdout, /PASS vault-path/); - assert.match(attachDoctor.stdout, /READY/); + assert.match(attachDoctor.stdout, /✅ workspace-files/); + assert.match(attachDoctor.stdout, /✅ vault-path/); + assert.doesNotMatch(attachDoctor.stdout, /PASS|FAIL|WARN|SKIP|READY/); const attachSync = run([ 'sync', '--workspace', attachWorkspace, '--dry-run', '--json', @@ -475,21 +475,19 @@ try { delete envWithoutHost.JARVOS_CONTROL_PLANE_SERVICE_MODULE; const doctorNoHost = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env: envWithoutHost }); assert.equal(doctorNoHost.status, 0, doctorNoHost.stderr || doctorNoHost.stdout); - assert.match(doctorNoHost.stdout, /PASS control-plane-module/); - assert.match(doctorNoHost.stdout, /host service not configured/); - assert.match(doctorNoHost.stdout, /READY/); + assert.match(doctorNoHost.stdout, /✅ control-plane-module/); + assert.doesNotMatch(doctorNoHost.stdout, /PASS|FAIL|WARN|SKIP|READY/); const doctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env }); assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout); - assert.match(doctor.stdout, /PASS node-version/); - assert.match(doctor.stdout, /PASS workspace-files/); - assert.match(doctor.stdout, /PASS config-schema/); - assert.match(doctor.stdout, /PASS vault-path/); - assert.match(doctor.stdout, /PASS vault-path-stale/); - assert.match(doctor.stdout, /PASS journal-conflict/); - assert.match(doctor.stdout, /PASS control-plane-module/); - assert.match(doctor.stdout, /authenticated host service/); - assert.match(doctor.stdout, /READY/); + assert.match(doctor.stdout, /✅ node-version/); + assert.match(doctor.stdout, /✅ workspace-files/); + assert.match(doctor.stdout, /✅ config-schema/); + assert.match(doctor.stdout, /✅ vault-path/); + assert.match(doctor.stdout, /✅ vault-path-stale/); + assert.match(doctor.stdout, /✅ journal-conflict/); + assert.match(doctor.stdout, /✅ control-plane-module/); + assert.doesNotMatch(doctor.stdout, /PASS|FAIL|WARN|SKIP|READY/); for (const file of [ 'AGENTS.md', @@ -505,15 +503,15 @@ try { } const syncDoctor = run(['doctor', '--profile', 'minimal', '--workspace', syncWorkspace], { env }); assert.equal(syncDoctor.status, 0, syncDoctor.stderr || syncDoctor.stdout); - assert.match(syncDoctor.stdout, /PASS config-schema/); - assert.match(syncDoctor.stdout, /PASS vault-path/); - assert.match(syncDoctor.stdout, /READY/); + assert.match(syncDoctor.stdout, /✅ config-schema/); + assert.match(syncDoctor.stdout, /✅ vault-path/); + assert.doesNotMatch(syncDoctor.stdout, /PASS|FAIL|WARN|SKIP|READY/); fs.rmSync(path.join(syncVault, 'Tags'), { recursive: true }); fs.writeFileSync(path.join(syncVault, 'Tags'), 'not a directory\n'); const fileTagsDoctor = run(['doctor', '--profile', 'minimal', '--workspace', syncWorkspace], { env }); assert.notEqual(fileTagsDoctor.status, 0); - assert.match(fileTagsDoctor.stdout, /FAIL vault-path/); + assert.match(fileTagsDoctor.stdout, /❌ vault-path/); fs.rmSync(path.join(syncVault, 'Tags')); fs.mkdirSync(path.join(syncVault, 'Tags')); @@ -531,7 +529,7 @@ try { fs.writeFileSync(path.join(workspace, 'jarvos.config.json'), JSON.stringify(configured, null, 2)); const duplicateTelegramDoctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env }); assert.equal(duplicateTelegramDoctor.status, 1, duplicateTelegramDoctor.stderr || duplicateTelegramDoctor.stdout); - assert.match(duplicateTelegramDoctor.stdout, /FAIL config-schema/); + assert.match(duplicateTelegramDoctor.stdout, /❌ config-schema/); assert.match(duplicateTelegramDoctor.stdout, /only one Telegram update consumer/); delete configured.runtimeMode; fs.writeFileSync(path.join(workspace, 'jarvos.config.json'), JSON.stringify(configured, null, 2)); @@ -572,11 +570,67 @@ try { }]); const moduleTextDoctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env }); assert.equal(moduleTextDoctor.status, 0, moduleTextDoctor.stderr || moduleTextDoctor.stdout); - assert.match(moduleTextDoctor.stdout, /jarvOS doctor — Minimal/); - assert.match(moduleTextDoctor.stdout, /Optional modules:\nMemory — update available/); - assert.match(moduleTextDoctor.stdout, /\nREADY\n\nSystem Doctor: READY\n$/); + assert.match(moduleTextDoctor.stdout, /jarvOS System Doctor — Minimal/); + assert.doesNotMatch(moduleTextDoctor.stdout, /Memory — update available/); + assert.doesNotMatch(moduleTextDoctor.stdout, /PASS|FAIL|WARN|SKIP|System Doctor:|READY/); + + fs.writeFileSync(memorySnapshotPath, `${JSON.stringify({ + schema: 'jarvos-health-module-snapshot/v1', + moduleId: 'memory', + generation: 2, + observedAt: '2026-08-12T23:00:00.000Z', + validUntil: '2099-08-13T23:00:00.000Z', + trust: 'trusted', + repairable: true, + updateAvailable: false, + })}\n`, 'utf8'); + const blockedMemoryDoctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env }); + assert.equal(blockedMemoryDoctor.status, 1, blockedMemoryDoctor.stderr || blockedMemoryDoctor.stdout); + assert.match(blockedMemoryDoctor.stdout, /❌ Memory receipt/); const systemSnapshotPath = path.join(healthModules, 'system.json'); + const memoryIds = [ + 'memory.gbrain', + 'memory.gbrain-semantic-coverage', + 'memory.lossless-claw', + 'memory.qmd-search', + 'memory.memory-wiki', + 'memory.notes-provenance', + 'memory.recall-evaluation', + 'memory.scheduled-maintenance', + 'memory.runtime-checkout', + 'memory.automatic-repair', + 'memory.notification-follow-up', + ]; + fs.writeFileSync(systemSnapshotPath, `${JSON.stringify({ + schema: 'jarvos-health-module-snapshot/v1', + moduleId: 'system', + generation: 2, + observedAt: '2026-08-12T23:00:00.000Z', + validUntil: '2099-08-13T23:00:00.000Z', + trust: 'trusted', + factsVersion: 'jarvos-system-doctor-facts/v2', + facts: { + profile: 'minimal', + components: memoryIds.map((id) => ({ id, state: 'healthy', reasonClass: 'none', evidence: null })), + }, + })}\n`, 'utf8'); + fs.chmodSync(systemSnapshotPath, 0o600); + const projectedMemoryDoctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env }); + assert.equal(projectedMemoryDoctor.status, 0, projectedMemoryDoctor.stderr || projectedMemoryDoctor.stdout); + assert.match(projectedMemoryDoctor.stdout, /Memory\n✅ GBrain core\n✅ GBrain semantic coverage/); + assert.doesNotMatch(projectedMemoryDoctor.stdout, /Memory receipt/); + fs.writeFileSync(memorySnapshotPath, `${JSON.stringify({ + schema: 'jarvos-health-module-snapshot/v1', + moduleId: 'memory', + generation: 3, + observedAt: '2026-08-12T23:00:00.000Z', + validUntil: '2099-08-13T23:00:00.000Z', + trust: 'trusted', + repairable: false, + updateAvailable: true, + })}\n`, 'utf8'); + fs.writeFileSync(systemSnapshotPath, `${JSON.stringify({ schema: 'jarvos-health-module-snapshot/v1', moduleId: 'system', @@ -584,7 +638,7 @@ try { observedAt: '2026-08-12T23:00:00.000Z', validUntil: '2099-08-13T23:00:00.000Z', trust: 'trusted', - factsVersion: 'jarvos-system-doctor-facts/v1', + factsVersion: 'jarvos-system-doctor-facts/v2', facts: { profile: 'minimal', components: [{ @@ -605,8 +659,8 @@ try { assert.equal(searxng.reasonClass, 'search-empty'); const systemTextDoctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env }); assert.equal(systemTextDoctor.status, 1, systemTextDoctor.stderr || systemTextDoctor.stdout); - assert.match(systemTextDoctor.stdout, /⚠️ WARN SearXNG — warning/); - assert.match(systemTextDoctor.stdout, /NOT READY — needs your attention/); + assert.match(systemTextDoctor.stdout, /⚠️ SearXNG — No search results\. Run a real search, then rerun Doctor\./); + assert.doesNotMatch(systemTextDoctor.stdout, /PASS|FAIL|WARN|SKIP|Selected optional components|System Doctor:|READY/); fs.unlinkSync(systemSnapshotPath); const localDoctorEnv = { @@ -641,7 +695,7 @@ try { observedAt: '2026-08-12T23:00:00.000Z', validUntil: '2099-08-13T23:00:00.000Z', trust: 'trusted', - factsVersion: 'jarvos-system-doctor-facts/v1', + factsVersion: 'jarvos-system-doctor-facts/v2', facts: { profile: 'local-openclaw', components: [{ id: 'provider.paperclip', state: 'warning', reasonClass: 'unavailable', evidence: null }], @@ -713,7 +767,7 @@ try { }; const doctorBadHost = run(['doctor', '--profile', 'minimal', '--workspace', workspace], { env: badHostEnv }); assert.notEqual(doctorBadHost.status, 0); - assert.match(doctorBadHost.stdout, /FAIL control-plane-module/); + assert.match(doctorBadHost.stdout, /❌ control-plane-module/); assert.match(doctorBadHost.stdout, /configure a usable JARVOS_CONTROL_PLANE_SERVICE_MODULE/); assert.doesNotMatch(doctorBadHost.stdout, /missing-host\.js/); diff --git a/tests/doctor-modules-test.js b/tests/doctor-modules-test.js index caacc9e7..c6ad44a0 100644 --- a/tests/doctor-modules-test.js +++ b/tests/doctor-modules-test.js @@ -137,7 +137,7 @@ function systemSnapshot(overrides = {}) { observedAt: NOW.toISOString(), validUntil: new Date(NOW.getTime() + 60 * 60 * 1000).toISOString(), trust: 'trusted', - factsVersion: 'jarvos-system-doctor-facts/v1', + factsVersion: 'jarvos-system-doctor-facts/v2', facts: { profile: 'minimal', components: [] }, ...overrides, }; @@ -177,7 +177,7 @@ test('a system snapshot for another profile fails closed', () => { assert.equal(report.modules[0].components, undefined); }); -test('Memory keeps its fixed ten-component roster and rejects partial or reordered projections', () => { +test('Memory keeps its fixed eleven-component roster and rejects partial or reordered projections', () => { const components = MEMORY_COMPONENTS.map(([id]) => systemComponent(id)); const root = workspace(); writeSnapshot(root, systemSnapshot({ facts: { profile: 'minimal', components } })); @@ -193,6 +193,31 @@ test('Memory keeps its fixed ten-component roster and rejects partial or reorder } }); +test('the obsolete ten-row v1 System Doctor facts fail closed', () => { + const root = workspace(); + writeSnapshot(root, systemSnapshot({ + factsVersion: 'jarvos-system-doctor-facts/v1', + facts: { + profile: 'minimal', + components: MEMORY_COMPONENTS + .filter(([id]) => id !== 'memory.gbrain-semantic-coverage') + .map(([id]) => systemComponent(id)), + }, + })); + const report = loadHealthModules({ workspace: root, now: NOW, profile: 'minimal' }); + assert.equal(report.modules[0].state, 'needs your attention'); + assert.equal(report.modules[0].reasonClass, 'module-invalid'); + assert.equal(report.modules[0].components, undefined); + const text = renderSystemDoctor({ + ok: false, + profile: { id: 'minimal', title: 'Minimal' }, + workspace: root, + results: [{ id: 'node-version', ok: true, message: 'Node.js is supported' }], + modules: report.modules, + }); + assert.match(text, /❌ System health receipt — Receipt is invalid\. Republish it\./); +}); + test('SearXNG cannot be healthy when HTTP responds but search and runtime-tool proof fail', () => { const root = workspace(); writeSnapshot(root, systemSnapshot({ @@ -254,10 +279,77 @@ test('the shared System Doctor receipt and text list core plus every selected co }; const receipt = buildSystemDoctorReceipt(report); assert.equal(receipt.schema, 'jarvos-system-doctor-report/v1'); - assert.equal(receipt.components.filter((component) => component.section === 'memory').length, 10); + assert.equal(receipt.components.filter((component) => component.section === 'memory').length, 11); const text = renderSystemDoctor({ ...report, systemDoctor: receipt }); - assert.match(text, /✅ PASS node-version — healthy/); + assert.match(text, /Core\n✅ node-version/); + assert.match(text, /Memory\n✅ GBrain core\n✅ GBrain semantic coverage/); for (const [, label] of MEMORY_COMPONENTS) assert.match(text, new RegExp(label.replace(/[&]/g, '\\&'))); + assert.doesNotMatch(text, /PASS|FAIL|WARN|SKIP|Selected optional components|System Doctor:|READY/); + assert.equal((text.match(/✅|❌|⚠️/g) || []).length, receipt.components.length); +}); + +test('operator text distinguishes a failure from an unverified component and gives each a next action', () => { + const report = { + ok: false, + profile: { id: 'minimal', title: 'Minimal' }, + workspace: '/portable/workspace', + results: [ + { id: 'workspace-files', ok: false, message: 'Required workspace file is missing' }, + { + id: 'optional-runtime', status: 'skipped', message: 'Runtime adapter is not installed', detail: 'Install it only when needed', + }, + ], + modules: [{ + id: 'system', + state: 'needs your attention', + reasonClass: 'component-degraded', + components: [ + { + id: 'provider.searxng', label: 'SearXNG', state: 'warning', reasonClass: 'search-empty', evidence: null, + }, + { + id: 'provider.paperclip', label: 'Paperclip', state: 'not configured', reasonClass: 'not-configured', evidence: null, + }, + { + id: 'memory.qmd', label: 'QMD search', state: 'warning', reasonClass: 'unavailable', evidence: null, + }, + ], + }], + }; + const text = renderSystemDoctor(report); + assert.match(text, /❌ workspace-files — Required workspace file is missing\. Fix it, then rerun Doctor\./); + assert.match(text, /⚠️ optional-runtime — Runtime adapter is not installed — Install it only when needed\. Configure it when needed\./); + assert.match(text, /⚠️ SearXNG — No search results\. Run a real search, then rerun Doctor\./); + assert.match(text, /⚠️ Paperclip — Not configured\. Configure it when needed\./); + assert.match(text, /⚠️ QMD search — unavailable\. Verify it, then rerun Doctor\./); + assert.equal((text.match(/✅|❌|⚠️/g) || []).length, 5); +}); + +test('blocking modules remain visible without duplicating a projected Memory roster', () => { + const base = { + ok: false, + profile: { id: 'minimal', title: 'Minimal' }, + workspace: '/portable/workspace', + results: [{ id: 'node-version', ok: true, message: 'Node.js is supported' }], + }; + const legacyOnly = renderSystemDoctor({ + ...base, + modules: [{ id: 'memory', state: 'repair needed', reasonClass: 'reported-condition' }], + }); + assert.match(legacyOnly, /❌ Memory receipt — reported condition\. Fix it, then rerun Doctor\./); + + const memory = MEMORY_COMPONENTS.map(([id, label]) => ({ + id, label, state: 'healthy', reasonClass: 'none', evidence: null, + })); + const projected = renderSystemDoctor({ + ...base, + modules: [ + { id: 'memory', state: 'repair needed', reasonClass: 'reported-condition' }, + { id: 'system', state: 'healthy', reasonClass: 'none', components: memory }, + ], + }); + assert.doesNotMatch(projected, /Memory receipt/); + assert.equal((projected.match(/✅ GBrain core/g) || []).length, 1); }); test('missing continuity evidence is visible only when the private profile requires it', () => { diff --git a/tests/runtime-neutral-templates.test.js b/tests/runtime-neutral-templates.test.js index b72c1a9b..ddc4c705 100644 --- a/tests/runtime-neutral-templates.test.js +++ b/tests/runtime-neutral-templates.test.js @@ -74,9 +74,15 @@ try { const doctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace], env); assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout); - assert.match(doctor.stdout, /PASS vault-path/); - assert.match(doctor.stdout, /PASS vault-path-stale/); - assert.match(doctor.stdout, /dormant runtime mode leaves vault unactivated/); + assert.match(doctor.stdout, /✅ vault-path/); + assert.match(doctor.stdout, /✅ vault-path-stale/); + const jsonDoctor = run(['doctor', '--profile', 'minimal', '--workspace', workspace, '--json'], env); + assert.equal(jsonDoctor.status, 0, jsonDoctor.stderr || jsonDoctor.stdout); + const doctorReceipt = JSON.parse(jsonDoctor.stdout); + assert.match( + doctorReceipt.results.find((result) => result.id === 'vault-path-stale').detail, + /dormant runtime mode leaves vault unactivated/, + ); const authoredAgents = '# Customized workspace instructions\n'; fs.writeFileSync(path.join(workspace, 'AGENTS.md'), authoredAgents, 'utf8');