diff --git a/modules/jarvos-runtime-kit/README.md b/modules/jarvos-runtime-kit/README.md index 1afefedc..eb2fdf52 100644 --- a/modules/jarvos-runtime-kit/README.md +++ b/modules/jarvos-runtime-kit/README.md @@ -6,6 +6,23 @@ Runtime adapters should stay thin. Shared jarvOS capabilities live in `@jarvos/agent-context`; adapter directories translate those capabilities into a host runtime's native surfaces such as MCP, hooks, skills, or desktop config. +## Operator notifications (unstable) + +`@jarvos/runtime-kit` also exports an unstable 0.x, transport-neutral operator +notification contract. Producers submit a versioned semantic event; the contract +validates reviewed fields and deterministically returns either plain-English +text or `NO_REPLY`. It never renders caller-supplied diagnostic prose. + +Use `evaluateOperatorNotification(event)` when a host needs the attention +policy, durable-status text, and dedupe identity. Use +`renderOperatorNotification(event)` when it only needs the direct output. +Action-required events include an opaque event reference; detailed codes, +paths, commits, receipts, and process output remain in owner-only evidence. +Safe holds are durable status, while routine safe repairs and resolutions are +quiet. Release-state events separately name published, approval-ready, and +future versions; stale or unknown observations use qualified wording rather +than claiming current publication or review readiness. + ## Commands ```bash diff --git a/modules/jarvos-runtime-kit/src/index.js b/modules/jarvos-runtime-kit/src/index.js index 8dad5515..a1bc4ae7 100644 --- a/modules/jarvos-runtime-kit/src/index.js +++ b/modules/jarvos-runtime-kit/src/index.js @@ -11,6 +11,7 @@ const stewardshipAdapter = require('./stewardship-adapter.js'); const stewardshipBootstrap = require('./stewardship-bootstrap.js'); const openclawPluginPersistence = require('./openclaw-plugin-persistence.js'); const capabilityDescriptor = require('./capability-descriptor.js'); +const operatorNotification = require('./operator-notification.js'); const DEFAULT_AGENT_CONTEXT_MCP = 'modules/jarvos-agent-context/scripts/jarvos-mcp.js'; const REQUIRED_MCP_TOOL = 'jarvos_hydrate'; @@ -881,6 +882,7 @@ module.exports = { ...stewardshipBootstrap, ...openclawPluginPersistence, ...capabilityDescriptor, + ...operatorNotification, DEFAULT_AGENT_CONTEXT_MCP, HYDRATION_MODES, REQUIRED_MCP_TOOL, diff --git a/modules/jarvos-runtime-kit/src/operator-notification.js b/modules/jarvos-runtime-kit/src/operator-notification.js new file mode 100644 index 00000000..fc2bdba7 --- /dev/null +++ b/modules/jarvos-runtime-kit/src/operator-notification.js @@ -0,0 +1,180 @@ +'use strict'; + +const crypto = require('crypto'); + +// This is intentionally an unstable 0.x contract. Producers must retain their +// detailed diagnostics privately and send this module only reviewed semantics. +const OPERATOR_NOTIFICATION_SCHEMA_VERSION = 'jarvos-operator-notification/v1'; +const NO_REPLY = 'NO_REPLY'; +const AUDIENCES = new Set(['operator']); +const SEVERITIES = new Set(['info', 'warning', 'error', 'security']); +const AUTOMATION_OUTCOMES = new Set(['none', 'safe-hold', 'repaired', 'resolved', 'failed']); +const FRESHNESS_STATES = new Set(['current', 'stale', 'unknown']); +const ACTIONS = new Set(['none', 'review-release', 'choose-recovery', 'review-safety-hold']); +const NEXT_STATES = new Set(['none', 'continue-monitoring', 'wait-for-fresh-observation', 'resume-after-review']); +const EVENT_CODES = new Set(['release-state', 'safety-hold', 'recovery-failed', 'repair-complete', 'resolution-complete']); +const EVENT_FIELDS = new Set([ + 'schemaVersion', 'code', 'audience', 'severity', 'automationOutcome', + 'actionRequired', 'action', 'nextState', 'eventReference', 'dedupeKey', + 'observedAt', 'freshness', 'privateDetailReference', 'release', +]); + +const ACTION_TEXT = { + 'review-release': 'Review the proposed release before it can publish.', + 'choose-recovery': 'Choose how jarvOS should proceed.', + 'review-safety-hold': 'Review the held change and choose whether to continue.', +}; +const NEXT_TEXT = { + 'continue-monitoring': 'jarvOS will continue monitoring safely.', + 'wait-for-fresh-observation': 'jarvOS will keep monitoring for a fresh observation.', + 'resume-after-review': 'after your review, jarvOS will continue the release process.', +}; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isSemanticVersion(value) { + return typeof value === 'string' && /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value); +} + +function isBoundedIdentifier(value, { min = 1, max = 120 } = {}) { + return typeof value === 'string' && value.length >= min && value.length <= max + && /^[a-z][a-z0-9-]*$/.test(value); +} + +function isOpaqueReference(value) { + // A base64url token of this length has enough entropy to be non-guessable + // when minted by the owner-authorized context. Its contents are never shown + // other than as the correlation reference for an action-required message. + return typeof value === 'string' && /^[A-Za-z0-9_-]{22,128}$/.test(value); +} + +function isIsoTime(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + && !Number.isNaN(Date.parse(value)); +} + +function validateRelease(release, errors) { + if (!isObject(release)) { + errors.push('release must be an object'); + return; + } + const allowed = new Set(['publishedVersion', 'approvalReadyVersion', 'futureVersion']); + for (const key of Object.keys(release)) if (!allowed.has(key)) errors.push(`release has unknown field: ${key}`); + for (const field of ['publishedVersion', 'approvalReadyVersion', 'futureVersion']) { + if (!isSemanticVersion(release[field])) errors.push(`release.${field} must be a semantic version`); + } +} + +function validateOperatorNotificationEvent(event) { + const errors = []; + if (!isObject(event)) return { ok: false, errors: ['operator notification event must be an object'] }; + for (const key of Object.keys(event)) if (!EVENT_FIELDS.has(key)) errors.push(`operator notification event has unknown field: ${key}`); + if (event.schemaVersion !== OPERATOR_NOTIFICATION_SCHEMA_VERSION) errors.push(`event.schemaVersion must be ${OPERATOR_NOTIFICATION_SCHEMA_VERSION}`); + if (!isBoundedIdentifier(event.code)) errors.push('event.code must be a bounded stable identifier'); + if (!AUDIENCES.has(event.audience)) errors.push('event.audience must be operator'); + if (!SEVERITIES.has(event.severity)) errors.push('event.severity is invalid'); + if (!AUTOMATION_OUTCOMES.has(event.automationOutcome)) errors.push('event.automationOutcome is invalid'); + if (typeof event.actionRequired !== 'boolean') errors.push('event.actionRequired must be boolean'); + if (!ACTIONS.has(event.action)) errors.push('event.action is invalid'); + if (!NEXT_STATES.has(event.nextState)) errors.push('event.nextState is invalid'); + if (event.actionRequired && event.action === 'none') errors.push('action-required events need a reviewed action'); + if (!event.actionRequired && event.action !== 'none') errors.push('non-action events must use action none'); + if (!isOpaqueReference(event.eventReference)) errors.push('event.eventReference must be an opaque reference'); + if (!isBoundedIdentifier(event.dedupeKey, { max: 180 })) errors.push('event.dedupeKey must be a bounded stable identifier'); + if (!isIsoTime(event.observedAt)) errors.push('event.observedAt must be an ISO-8601 UTC timestamp'); + if (!FRESHNESS_STATES.has(event.freshness)) errors.push('event.freshness is invalid'); + if (event.privateDetailReference !== undefined && !isOpaqueReference(event.privateDetailReference)) errors.push('event.privateDetailReference must be an opaque reference'); + if (event.code === 'release-state') validateRelease(event.release, errors); + if (event.code !== 'release-state' && event.release !== undefined) errors.push('release is only valid for release-state events'); + if (event.code === 'release-state' && event.freshness !== 'current' && event.actionRequired) errors.push('stale or unknown release evidence cannot request release review'); + return { ok: errors.length === 0, errors, value: event }; +} + +function assertOperatorNotificationEvent(event) { + const validation = validateOperatorNotificationEvent(event); + if (!validation.ok) throw new TypeError(validation.errors.join('; ')); + return event; +} + +function notificationDedupeIdentity(event) { + assertOperatorNotificationEvent(event); + return crypto.createHash('sha256') + .update(`${event.schemaVersion}\0${event.code}\0${event.dedupeKey}\0${event.actionRequired}\0${event.freshness}`) + .digest('hex'); +} + +function releaseMessage(event) { + const { publishedVersion, approvalReadyVersion, futureVersion } = event.release; + if (event.freshness === 'current') { + return `jarvOS ${publishedVersion} is currently published. A proposed ${approvalReadyVersion} release has passed checks and is ready for Andrew's review; nothing will publish automatically. The separate ${futureVersion} milestone remains future work.`; + } + const qualifier = event.freshness === 'stale' ? 'stale' : 'unconfirmed'; + return `jarvOS last observed ${publishedVersion} as published, but that observation is ${qualifier}. The proposed ${approvalReadyVersion} release needs a fresh check before it can be reviewed. The separate ${futureVersion} milestone remains future work.`; +} + +function eventMessage(event) { + if (event.code === 'release-state') return releaseMessage(event); + if (event.code === 'safety-hold') return 'jarvOS paused an unsafe change and left the existing setup unchanged.'; + if (event.code === 'recovery-failed') return 'jarvOS could not complete a safe recovery and preserved the existing state.'; + if (event.code === 'repair-complete') return 'jarvOS completed a safe repair.'; + if (event.code === 'resolution-complete') return 'jarvOS resolved the condition safely.'; + return 'jarvOS needs a reviewed operator decision and preserved the existing state.'; +} + +function renderMessage(event) { + if (!EVENT_CODES.has(event.code)) { + return `jarvOS needs an operator decision and preserved the existing state. Action required: Review the condition before jarvOS continues. Next: jarvOS will wait for your direction. Reference: ${event.eventReference}.`; + } + const parts = [eventMessage(event)]; + if (event.actionRequired) { + parts.push(`Action required: ${ACTION_TEXT[event.action]}`); + parts.push(`Next: ${NEXT_TEXT[event.nextState] || 'jarvOS will wait for your direction.'}`); + parts.push(`Reference: ${event.eventReference}.`); + } else { + parts.push('No action is needed from you.'); + if (event.nextState !== 'none') parts.push(`Next: ${NEXT_TEXT[event.nextState]}`); + } + return parts.join(' '); +} + +function evaluateOperatorNotification(event) { + assertOperatorNotificationEvent(event); + const knownCode = EVENT_CODES.has(event.code); + const direct = !knownCode || event.actionRequired; + const message = renderMessage(event); + if (direct) { + return { + disposition: 'direct-notification', + output: message, + statusMessage: null, + dedupeIdentity: notificationDedupeIdentity(event), + }; + } + const durableStatus = event.automationOutcome === 'safe-hold' + || (event.code === 'release-state' && event.freshness !== 'current'); + return { + disposition: durableStatus ? 'durable-status' : 'quiet', + output: NO_REPLY, + statusMessage: durableStatus ? message : null, + dedupeIdentity: notificationDedupeIdentity(event), + }; +} + +function renderOperatorNotification(event) { + return evaluateOperatorNotification(event).output; +} + +module.exports = { + ACTIONS, + AUTOMATION_OUTCOMES, + FRESHNESS_STATES, + NO_REPLY, + OPERATOR_NOTIFICATION_SCHEMA_VERSION, + assertOperatorNotificationEvent, + evaluateOperatorNotification, + notificationDedupeIdentity, + renderOperatorNotification, + validateOperatorNotificationEvent, +}; diff --git a/modules/jarvos-runtime-kit/test/operator-notification.test.js b/modules/jarvos-runtime-kit/test/operator-notification.test.js new file mode 100644 index 00000000..21a17dfb --- /dev/null +++ b/modules/jarvos-runtime-kit/test/operator-notification.test.js @@ -0,0 +1,146 @@ +'use strict'; + +const assert = require('assert'); +const test = require('node:test'); +const { + NO_REPLY, + OPERATOR_NOTIFICATION_SCHEMA_VERSION, + evaluateOperatorNotification, + notificationDedupeIdentity, + renderOperatorNotification, + validateOperatorNotificationEvent, +} = require('../src'); + +const EVENT_REFERENCE = 'uTQf8DG1p9Ck5Lm3Nw2RzAqB'; + +function event(overrides = {}) { + return { + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + code: 'recovery-failed', + audience: 'operator', + severity: 'error', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-recovery', + nextState: 'continue-monitoring', + eventReference: EVENT_REFERENCE, + dedupeKey: 'recovery-window-42', + observedAt: '2026-08-16T12:30:00Z', + freshness: 'current', + privateDetailReference: 'jJ3xbPq7YvmT0n6eC1fKrS9D', + ...overrides, + }; +} + +test('action-required events answer what happened, what jarvOS did, the action, and next step', () => { + const output = renderOperatorNotification(event()); + assert.equal(output, "jarvOS could not complete a safe recovery and preserved the existing state. Action required: Choose how jarvOS should proceed. Next: jarvOS will continue monitoring safely. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB."); +}); + +test('safe holds remain durable status with first-seen and occurrence information outside the renderer', () => { + const result = evaluateOperatorNotification(event({ + code: 'safety-hold', + severity: 'warning', + automationOutcome: 'safe-hold', + actionRequired: false, + action: 'none', + nextState: 'continue-monitoring', + })); + assert.equal(result.output, NO_REPLY); + assert.equal(result.disposition, 'durable-status'); + assert.equal(result.statusMessage, 'jarvOS paused an unsafe change and left the existing setup unchanged. No action is needed from you. Next: jarvOS will continue monitoring safely.'); +}); + +test('routine safe repairs and resolutions stay quiet', () => { + const result = evaluateOperatorNotification(event({ + code: 'repair-complete', + severity: 'info', + automationOutcome: 'repaired', + actionRequired: false, + action: 'none', + nextState: 'none', + })); + assert.equal(result.output, NO_REPLY); + assert.equal(result.disposition, 'quiet'); + assert.equal(result.statusMessage, null); +}); + +test('validation rejects free prose and private or raw diagnostic fields before rendering', () => { + for (const unsafe of [ + { diagnostic: 'unsafe_source at /Users/andrew/private' }, + { action: 'call the private skill immediately' }, + { nextState: 'read receipt-123 and retry' }, + { sourceSha: '8cb3909' }, + { stack: 'Error: failed\n at private.js:1:1' }, + ]) { + const result = validateOperatorNotificationEvent(event(unsafe)); + assert.equal(result.ok, false, JSON.stringify(result.errors)); + } +}); + +test('unknown codes render reviewed generic action-required text without the code', () => { + const output = renderOperatorNotification(event({ code: 'new-private-machine-code', actionRequired: false, action: 'none' })); + assert.equal(output, 'jarvOS needs an operator decision and preserved the existing state. Action required: Review the condition before jarvOS continues. Next: jarvOS will wait for your direction. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB.'); + assert.equal(output.includes('new-private-machine-code'), false); +}); + +test('evaluation and dedupe identity are deterministic', () => { + const input = event(); + assert.deepEqual(evaluateOperatorNotification(input), evaluateOperatorNotification(input)); + assert.equal(notificationDedupeIdentity(input), notificationDedupeIdentity(input)); +}); + +test('current release evidence distinguishes published, approval-ready, and future lanes', () => { + const output = renderOperatorNotification(event({ + code: 'release-state', + severity: 'info', + automationOutcome: 'none', + actionRequired: true, + action: 'review-release', + nextState: 'resume-after-review', + release: { + publishedVersion: '0.7.0', + approvalReadyVersion: '0.8.0', + futureVersion: 'v1.0.0', + }, + })); + assert.equal(output, "jarvOS 0.7.0 is currently published. A proposed 0.8.0 release has passed checks and is ready for Andrew's review; nothing will publish automatically. The separate v1.0.0 milestone remains future work. Action required: Review the proposed release before it can publish. Next: after your review, jarvOS will continue the release process. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB."); + assert.equal(output.includes('8cb3909'), false); +}); + +test('stale or unknown release evidence uses qualified wording and stays quiet when no action is required', () => { + for (const freshness of ['stale', 'unknown']) { + const result = evaluateOperatorNotification(event({ + code: 'release-state', + severity: 'warning', + automationOutcome: 'none', + actionRequired: false, + action: 'none', + nextState: 'wait-for-fresh-observation', + freshness, + release: { + publishedVersion: '0.7.0', + approvalReadyVersion: '0.8.0', + futureVersion: 'v1.0.0', + }, + })); + assert.equal(result.output, NO_REPLY); + assert.equal(result.disposition, 'durable-status'); + assert.match(result.statusMessage, /last observed 0\.7\.0 as published/); + assert.doesNotMatch(result.statusMessage, /currently published|ready for Andrew's review/); + assert.equal(renderOperatorNotification(event({ + code: 'release-state', severity: 'warning', automationOutcome: 'safe-hold', actionRequired: false, + action: 'none', nextState: 'wait-for-fresh-observation', freshness, + release: { publishedVersion: '0.7.0', approvalReadyVersion: '0.8.0', futureVersion: 'v1.0.0' }, + })), NO_REPLY); + } +}); + +test('stale release evidence cannot request approval', () => { + const result = validateOperatorNotificationEvent(event({ + code: 'release-state', action: 'review-release', freshness: 'stale', + release: { publishedVersion: '0.7.0', approvalReadyVersion: '0.8.0', futureVersion: 'v1.0.0' }, + })); + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /cannot request release review/); +});